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
-
Version only for breaking changes - Don’t create new versions for backward-compatible updates
-
Document changes between versions - Maintain clear changelogs for each version
-
Set clear deprecation policies - Specify how long older versions will be supported
-
Make the latest version the default - New clients should use the most recent version
-
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.