email-triage-openenv / api_versioning.py
ervjn455's picture
Upload folder using huggingface_hub
0ab6c82 verified
Raw
History Blame Contribute Delete
15.4 kB
"""
API Versioning System
Support for multiple API versions with deprecation and migration
"""
import re
import functools
import logging
from datetime import datetime
from typing import Dict, List, Any, Optional, Callable, Union
from dataclasses import dataclass, field
from enum import Enum
logger = logging.getLogger("api_versioning")
class VersionStatus(Enum):
"""API version status"""
CURRENT = "current" # Active and recommended
SUPPORTED = "supported" # Active but not recommended
DEPRECATED = "deprecated" # Still working but will be removed
RETIRED = "retired" # No longer available
@dataclass
class APIVersion:
"""API version definition"""
version: str
status: VersionStatus
release_date: datetime
deprecation_date: Optional[datetime] = None
retirement_date: Optional[datetime] = None
description: str = ""
breaking_changes: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"version": self.version,
"status": self.status.value,
"release_date": self.release_date.isoformat(),
"deprecation_date": self.deprecation_date.isoformat() if self.deprecation_date else None,
"retirement_date": self.retirement_date.isoformat() if self.retirement_date else None,
"description": self.description,
"breaking_changes": self.breaking_changes
}
@dataclass
class EndpointVersion:
"""Versioned endpoint definition"""
path: str
method: str
version: str
handler: Callable
deprecated: bool = False
deprecated_message: str = ""
replacement_endpoint: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"path": self.path,
"method": self.method,
"version": self.version,
"deprecated": self.deprecated,
"deprecated_message": self.deprecated_message,
"replacement_endpoint": self.replacement_endpoint
}
@dataclass
class MigrationRule:
"""Request/response migration rule"""
from_version: str
to_version: str
field: str
action: str # rename, transform, remove, add
old_value: Optional[str] = None
new_value: Optional[str] = None
transformer: Optional[Callable] = None
def apply_to_request(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Apply migration to request data"""
result = data.copy()
if self.action == "rename" and self.old_value in result:
result[self.new_value] = result.pop(self.old_value)
elif self.action == "transform" and self.field in result:
if self.transformer:
result[self.field] = self.transformer(result[self.field])
elif self.action == "remove" and self.field in result:
del result[self.field]
elif self.action == "add" and self.field not in result:
result[self.field] = self.new_value
return result
def apply_to_response(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Apply migration to response data (reverse direction)"""
result = data.copy()
if self.action == "rename" and self.new_value in result:
result[self.old_value] = result.pop(self.new_value)
elif self.action == "add" and self.field in result:
# Remove field that was added for newer version
del result[self.field]
return result
class APIVersionManager:
"""
Production API Versioning System
Features:
- Multiple API versions (v1, v2, etc.)
- Version detection (URL, header, query param)
- Automatic migration between versions
- Deprecation warnings
- Version analytics
"""
def __init__(self):
self._versions: Dict[str, APIVersion] = {}
self._endpoints: Dict[str, List[EndpointVersion]] = {}
self._migrations: Dict[str, List[MigrationRule]] = {}
self._version_usage: Dict[str, int] = {}
self._endpoint_usage: Dict[str, int] = {}
# Register default versions
self._register_default_versions()
logger.info("API Version Manager initialized")
def _register_default_versions(self):
"""Register default API versions"""
self.register_version(APIVersion(
version="v1",
status=VersionStatus.SUPPORTED,
release_date=datetime(2024, 1, 1),
description="Initial API version",
breaking_changes=[]
))
self.register_version(APIVersion(
version="v2",
status=VersionStatus.CURRENT,
release_date=datetime(2024, 6, 1),
description="Current API version with enhanced features",
breaking_changes=[
"Changed email response format",
"Added required 'priority' field",
"Renamed 'category' to 'classification'"
]
))
def register_version(self, version: APIVersion):
"""Register an API version"""
self._versions[version.version] = version
self._version_usage[version.version] = 0
logger.info(f"Registered API version: {version.version}")
def register_endpoint(
self,
path: str,
method: str,
version: str,
handler: Callable,
deprecated: bool = False,
deprecated_message: str = "",
replacement_endpoint: str = ""
):
"""Register a versioned endpoint"""
endpoint = EndpointVersion(
path=path,
method=method.upper(),
version=version,
handler=handler,
deprecated=deprecated,
deprecated_message=deprecated_message,
replacement_endpoint=replacement_endpoint
)
key = f"{method.upper()}:{path}"
if key not in self._endpoints:
self._endpoints[key] = []
self._endpoints[key].append(endpoint)
self._endpoint_usage[f"{version}:{key}"] = 0
def register_migration(self, migration: MigrationRule):
"""Register a migration rule"""
key = f"{migration.from_version}->{migration.to_version}"
if key not in self._migrations:
self._migrations[key] = []
self._migrations[key].append(migration)
def get_version(self, version: str) -> Optional[APIVersion]:
"""Get version info"""
return self._versions.get(version)
def get_current_version(self) -> str:
"""Get current API version"""
for v, info in self._versions.items():
if info.status == VersionStatus.CURRENT:
return v
return "v2" # Default
def detect_version(
self,
url: str = None,
headers: Dict[str, str] = None,
query_params: Dict[str, str] = None
) -> str:
"""Detect API version from request"""
# Check URL path first (e.g., /api/v1/emails)
if url:
match = re.search(r'/v(\d+)/', url)
if match:
version = f"v{match.group(1)}"
if version in self._versions:
return version
# Check header (e.g., X-API-Version: v2)
if headers:
header_version = headers.get("X-API-Version") or headers.get("x-api-version")
if header_version and header_version in self._versions:
return header_version
# Check query param (e.g., ?api_version=v1)
if query_params:
param_version = query_params.get("api_version")
if param_version and param_version in self._versions:
return param_version
# Default to current version
return self.get_current_version()
def get_endpoint(
self,
path: str,
method: str,
version: str
) -> Optional[EndpointVersion]:
"""Get endpoint for version"""
key = f"{method.upper()}:{path}"
endpoints = self._endpoints.get(key, [])
# Find exact version match
for ep in endpoints:
if ep.version == version:
return ep
# Fall back to lower versions
version_num = int(version[1:]) if version.startswith("v") else 1
for v in range(version_num - 1, 0, -1):
for ep in endpoints:
if ep.version == f"v{v}":
return ep
return None
def migrate_request(
self,
data: Dict[str, Any],
from_version: str,
to_version: str
) -> Dict[str, Any]:
"""Migrate request data between versions"""
result = data.copy()
from_num = int(from_version[1:]) if from_version.startswith("v") else 1
to_num = int(to_version[1:]) if to_version.startswith("v") else 1
if from_num < to_num:
# Upgrade path
for v in range(from_num, to_num):
key = f"v{v}->v{v+1}"
for migration in self._migrations.get(key, []):
result = migration.apply_to_request(result)
else:
# Downgrade path
for v in range(from_num, to_num, -1):
key = f"v{v-1}->v{v}"
for migration in self._migrations.get(key, []):
result = migration.apply_to_response(result)
return result
def migrate_response(
self,
data: Dict[str, Any],
from_version: str,
to_version: str
) -> Dict[str, Any]:
"""Migrate response data to target version"""
return self.migrate_request(data, from_version, to_version)
def get_deprecation_warning(
self,
version: str,
endpoint: str = None
) -> Optional[str]:
"""Get deprecation warning if applicable"""
ver = self._versions.get(version)
if ver and ver.status == VersionStatus.DEPRECATED:
msg = f"API version {version} is deprecated."
if ver.retirement_date:
msg += f" It will be retired on {ver.retirement_date.strftime('%Y-%m-%d')}."
return msg
# Check specific endpoint
if endpoint:
key = endpoint
for ep_list in self._endpoints.values():
for ep in ep_list:
if ep.path == endpoint and ep.version == version and ep.deprecated:
return ep.deprecated_message or f"Endpoint {endpoint} is deprecated."
return None
def track_usage(self, version: str, endpoint: str = None):
"""Track API version/endpoint usage"""
if version in self._version_usage:
self._version_usage[version] += 1
if endpoint:
key = f"{version}:{endpoint}"
self._endpoint_usage[key] = self._endpoint_usage.get(key, 0) + 1
def version_handler(self, target_version: str = None):
"""Decorator for version-aware handlers"""
def decorator(func: Callable):
@functools.wraps(func)
async def wrapper(request, *args, **kwargs):
# Detect version
version = self.detect_version(
url=str(request.url),
headers=dict(request.headers),
query_params=dict(request.query_params)
)
# Track usage
self.track_usage(version, request.url.path)
# Add deprecation warning to response headers
warning = self.get_deprecation_warning(version, request.url.path)
# Execute handler
response = await func(request, *args, version=version, **kwargs)
# Add headers if Response object
if warning and hasattr(response, 'headers'):
response.headers["X-API-Deprecation-Warning"] = warning
return response
return wrapper
return decorator
def list_versions(self) -> List[Dict[str, Any]]:
"""List all API versions"""
return [v.to_dict() for v in self._versions.values()]
def list_endpoints(self, version: str = None) -> List[Dict[str, Any]]:
"""List all endpoints"""
result = []
for key, endpoints in self._endpoints.items():
for ep in endpoints:
if version is None or ep.version == version:
result.append(ep.to_dict())
return result
def get_analytics(self) -> Dict[str, Any]:
"""Get version analytics"""
total_requests = sum(self._version_usage.values())
version_stats = {}
for v, count in self._version_usage.items():
version_info = self._versions.get(v)
version_stats[v] = {
"requests": count,
"percentage": (count / total_requests * 100) if total_requests > 0 else 0,
"status": version_info.status.value if version_info else "unknown"
}
# Top endpoints
top_endpoints = sorted(
self._endpoint_usage.items(),
key=lambda x: x[1],
reverse=True
)[:20]
# Deprecated usage
deprecated_count = sum(
self._version_usage.get(v, 0)
for v, info in self._versions.items()
if info.status == VersionStatus.DEPRECATED
)
return {
"total_requests": total_requests,
"version_count": len(self._versions),
"endpoint_count": sum(len(eps) for eps in self._endpoints.values()),
"migration_rules": sum(len(m) for m in self._migrations.values()),
"by_version": version_stats,
"top_endpoints": dict(top_endpoints),
"deprecated_usage": deprecated_count,
"deprecated_percentage": (deprecated_count / total_requests * 100) if total_requests > 0 else 0
}
# Global instance
_version_manager: Optional[APIVersionManager] = None
def get_version_manager() -> APIVersionManager:
"""Get global version manager"""
global _version_manager
if _version_manager is None:
_version_manager = APIVersionManager()
return _version_manager
def v1_response(data: Dict[str, Any]) -> Dict[str, Any]:
"""Transform response to v1 format"""
result = data.copy()
# V1 compatibility transformations
if "classification" in result:
result["category"] = result.pop("classification")
if "priority" in result:
del result["priority"] # V1 didn't have priority
return result
def v2_response(data: Dict[str, Any]) -> Dict[str, Any]:
"""Transform response to v2 format"""
result = data.copy()
# V2 format
if "category" in result:
result["classification"] = result.pop("category")
if "priority" not in result:
result["priority"] = "normal"
return result