API Versioning Strategy

Implementing API Versioning With AWS API Gateway and Serverless Framework

API versioning allows you to evolve your APIs without breaking existing clients. This guide shows you how to implement different versioning strategies using AWS API Gateway and the Serverless Framework.

Versioning Approaches

1. URL Path Versioning

Including the version directly in the URL path.

https://api.example.com/v1/resources/123
https://api.example.com/v2/resources/123

Serverless.yml configuration:

provider:
  environment:
    API_VERSION: ${opt:version, '1'}

custom:
  apiVersion: ${opt:version, '1'}
  customDomain:
    basePath: v${self:custom.apiVersion}

functions:
  getResource:
    handler: handlers/resource.get
    events:
      - http:
          path: /resources/{resourceId}
          method: get

Handler code:

def get(event, context):
    version = os.environ.get('API_VERSION', '1')
    resource_id = event['pathParameters']['resourceId']
    
    response_data = {
        'id': resource_id,
        'name': f'Resource {resource_id}',
        'version': f'v{version}'
    }
    
    return {
        'statusCode': 200,
        'headers': {'Content-Type': 'application/json'},
        'body': json.dumps(response_data)
    }

Deployment:

sls deploy --version 1  # Deploys to v1
sls deploy --version 2  # Deploys to v2

Pros:

  • Clear and explicit versioning
  • Easy to route and cache
  • Simple for API consumers to understand

Cons:

  • Requires changing URLs between versions
  • Creates multiple API deployments to maintain

2. Query Parameter Versioning

Using a query parameter to specify the version.

https://api.example.com/resources/123?version=1
https://api.example.com/resources/123?version=2

Serverless.yml configuration:

functions:
  getResource:
    handler: handlers/resource.get
    events:
      - http:
          path: /resources/{resourceId}
          method: get
          request:
            parameters:
              querystrings:
                version: false  # Optional parameter

Handler code:

def get(event, context):
    resource_id = event['pathParameters']['resourceId']
    
    # Extract version from query parameter
    query_params = event.get('queryStringParameters', {}) or {}
    version = query_params.get('version', '1')  # Default to v1
    
    # Version-specific logic
    if version == '1':
        return handle_v1(resource_id)
    elif version == '2':
        return handle_v2(resource_id)
    else:
        return handle_v1(resource_id)  # Default to v1

Pros:

  • Keeps URLs consistent
  • Single API deployment handles all versions
  • Backward compatible with existing URLs

Cons:

  • Less explicit than URL path versioning
  • Can make caching more complex
  • Query parameters might be stripped by proxies

3. Custom Header Versioning

Using a custom HTTP header to specify the version.

# Client call
curl -H "X-Api-Version: 2" https://api.example.com/resources/123

Serverless.yml configuration:

functions:
  getResource:
    handler: handlers/resource.get
    events:
      - http:
          path: /resources/{resourceId}
          method: get
          cors:
            headers:
              - X-Api-Version

Handler code:

def get(event, context):
    resource_id = event['pathParameters']['resourceId']
    
    # Extract version from custom header
    headers = event.get('headers', {}) or {}
    version = headers.get('X-Api-Version', '1')  # Default to v1
    
    # Version-specific logic
    if version == '1':
        return handle_v1(resource_id)
    elif version == '2':
        return handle_v2(resource_id)
    else:
        return handle_v1(resource_id)

Pros:

  • Keeps URLs clean
  • Separates versioning from resource identification
  • Works well with API gateways that route based on headers

Cons:

  • Less visible for testing
  • Requires CORS configuration for the custom header
  • Can be filtered by some proxies

4. Content Negotiation Versioning

Using the HTTP Accept header with media type to specify the version.

# Client call
curl -H "Accept: application/vnd.company.resource.v2+json" https://api.example.com/resources/123

Handler code:

def get(event, context):
    resource_id = event['pathParameters']['resourceId']
    
    # Extract version from Accept header
    headers = event.get('headers', {}) or {}
    accept_header = headers.get('Accept', 'application/json')
    
    # Parse version from Accept header
    version = '1'  # Default
    if 'application/vnd.company.resource.' in accept_header:
        version_match = re.search(r'v(\d+)\+json', accept_header)
        if version_match:
            version = version_match.group(1)
    
    # Version-specific logic
    if version == '1':
        return handle_v1(resource_id)
    elif version == '2':
        return handle_v2(resource_id)
    else:
        return handle_v1(resource_id)

Pros:

  • Most RESTful approach
  • Clean URLs with no version information
  • Works well with content negotiation patterns

Cons:

  • More complex to implement
  • Less intuitive for API consumers
  • Requires specialized client code

Implementing a Multi-Version Handler

This example handler supports all versioning approaches simultaneously:

def get(event, context):
    resource_id = event['pathParameters']['resourceId']
    version = determine_version(event)
    
    if version == '1':
        return handle_v1(resource_id)
    elif version == '2':
        return handle_v2(resource_id)
    else:
        return handle_v1(resource_id)

def determine_version(event):
    """Determine version using multiple strategies (in priority order)"""
    headers = event.get('headers', {}) or {}
    query_params = event.get('queryStringParameters', {}) or {}
    
    # 1. Check custom header
    if 'X-Api-Version' in headers:
        return headers.get('X-Api-Version')
    
    # 2. Check query parameter
    if 'version' in query_params:
        return query_params.get('version')
    
    # 3. Check content negotiation
    accept = headers.get('Accept', '')
    if 'application/vnd.company.resource.' in accept:
        version_match = re.search(r'v(\d+)\+json', accept)
        if version_match:
            return version_match.group(1)
    
    # 4. Fall back to URL path (from environment variable)
    return os.environ.get('API_VERSION', '1')

Database Strategies for Different Versions

Option 1: Schema Migrations with Transformation

Use a single database schema but transform data for different API versions:

def get_user(user_id, version):
    # Get data from a single database schema
    user = db.users.find_one({"id": user_id})
    
    # Transform based on version
    if version == "1":
        return {
            "id": user["id"],
            "name": user["name"],
            "email": user["email"]
        }
    elif version == "2":
        return {
            "id": user["id"],
            "name": user["name"],
            "email": user["email"],
            "profile": {
                "avatar": user.get("avatar_url"),
                "bio": user.get("bio", "")
            }
        }

Option 2: Version-Specific Storage

Use separate tables or collections for different versions:

def get_user(user_id, version):
    if version == "1":
        return db.users_v1.find_one({"id": user_id})
    elif version == "2":
        return db.users_v2.find_one({"id": user_id})

Monitoring Version Usage

Add version tracking to your logs and metrics:

def log_request(event, version):
    logger.info({
        "event": "api_request",
        "version": version,
        "path": event.get('path'),
        "method": event.get('httpMethod')
    })
    
    # Send metrics to CloudWatch
    cloudwatch.put_metric_data(
        Namespace='ApiMetrics',
        MetricData=[{
            'MetricName': 'RequestCount',
            'Dimensions': [
                {'Name': 'ApiVersion', 'Value': f'v{version}'}
            ],
            'Value': 1,
            'Unit': 'Count'
        }]
    )

Version Discovery Endpoint

Create an endpoint to show available versions:

def get_versions(event, context):
    versions = [
        {
            "version": "1",
            "status": "deprecated",
            "sunset_date": "2023-12-31"
        },
        {
            "version": "2",
            "status": "current",
            "sunset_date": null
        }
    ]
    
    return {
        'statusCode': 200,
        'body': json.dumps({"versions": versions})
    }

Best Practices

  1. Version only for breaking changes - Don’t create new versions for backward-compatible updates

  2. Document changes between versions - Maintain clear changelogs for each version

  3. Set clear deprecation policies - Specify how long older versions will be supported

  4. Make the latest version the default - New clients should use the most recent version

  5. Use semantic versioning principles - Major version changes for breaking changes

Choosing the Right Approach

Approach Best For
URL Path Public APIs with infrequent major version changes
Query Param Internal APIs with frequent minor updates
Custom Header APIs where you control most clients
Content Negotiation RESTful APIs with sophisticated content handling

The best choice depends on your specific API consumers, deployment infrastructure, and organizational practices. For maximum flexibility, you can support multiple versioning methods simultaneously.

5 Likes