API Versioning Mini Project — Complete Guide
In this tutorial, you'll build a complete API Versioning System. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
This mini project combines everything you learned: URI versioning, header versioning, content negotiation, deprecation management, sunset headers, and automated testing into a single working API versioning system.
What You'll Learn
By the end of this lesson, you will build a production-ready API versioning system with multi-strategy routing, deprecation tracking, and automated compatibility testing.
Why It Matters
A well-designed versioning system is invisible to happy clients. They should never think about versions unless something is about to break. This project gives you a template for that system.
Real-World Use
This project patterns after how major API providers like GitHub, Stripe, and Twilio manage multi-version APIs with deprecation notice, Migration guides, and smooth rollouts.
Project Architecture
flowchart TD
R[Request] --> Router[Version Router]
Router --> URI{URI Check}
URI -->|/v1/*| V1[V1 Handler]
URI -->|/v2/*| V2[V2 Handler]
URI -->|No version| Header{Header Check}
Header -->|Accept| Negotiate[Content Negotiation]
Header -->|X-API-Version| HeaderV
Negotiate --> V1
Negotiate --> V2
HeaderV --> V1
HeaderV --> V2
V1 --> Deprecate[Deprecation Monitor]
V2 --> Sunset[Sunset Header]
Deprecate --> Response
Sunset --> Response
Project Structure
versioning_project/
main.py # Main versioning system
version_router.py # Multi-strategy router
version_handlers.py # V1 and V2 handlers
deprecation.py # Deprecation tracking
sunset.py # Sunset headers
contract_tests.py # Contract testing
test_matrix.py # Version matrix tests
Version Router Implementation
# version_router.py
import re
from typing import Any, Callable, Dict, Optional, Tuple
class VersionRouter:
def __init__(self, default_version: str = "v1"):
self.default = default_version
self.handlers: Dict[int, Callable] = {}
def register(self, version: int, handler: Callable):
self.handlers[version] = handler
def _from_uri(self, path: str) -> Tuple[Optional[int], str]:
m = re.match(r"/(v?)(\d+)(/.*)?$", path)
if m:
return int(m.group(2)), m.group(3) or "/"
return None, path
def _from_accept(self, headers: Dict) -> Optional[int]:
accept = headers.get("Accept", "")
m = re.search(r"application/vnd\.[^.]*\.v(\d+)\+", accept)
if m:
return int(m.group(1))
return None
def _from_custom_header(self, headers: Dict) -> Optional[int]:
ver = headers.get("X-API-Version")
if ver:
try:
return int(ver)
except (ValueError, TypeError):
return None
return None
def _from_query(self, params: Dict) -> Optional[int]:
ver = params.get("version")
if ver:
try:
return int(ver)
except (ValueError, TypeError):
return None
return None
def resolve(self, path: str, headers: Dict,
params: Dict = None) -> Tuple[int, str, str]:
params = params or {}
uri_v, clean_path = self._from_uri(path)
version = (
uri_v
or self._from_accept(headers)
or self._from_custom_header(headers)
or self._from_query(params)
)
if version is None:
version = int(self.default[1:])
handler = self.handlers.get(version)
if not handler:
available = sorted(self.handlers.keys())
fallback_v = available[-1]
return fallback_v, clean_path, f"warning: v{version} not found, using v{fallback_v}"
source = "uri" if uri_v else "header" if self._from_accept(headers) else "custom" if self._from_custom_header(headers) else "query" if self._from_query(params) else "default"
return version, clean_path, source
Handler Implementations
# version_handlers.py
from datetime import datetime
from typing import Any, Dict, List
class V1Handler:
def handle_users(self) -> Dict:
return {
"users": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
],
"version": "v1",
}
def handle_user(self, user_id: int) -> Dict:
return {
"user": {"id": user_id, "name": f"User {user_id}"},
"version": "v1",
}
class V2Handler:
def handle_users(self) -> Dict:
return {
"data": [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"},
],
"meta": {
"count": 2,
"version": "v2",
"timestamp": datetime.now().isoformat(),
},
}
def handle_user(self, user_id: int) -> Dict:
return {
"data": {
"id": user_id,
"name": f"User {user_id}",
"email": f"user{user_id}@example.com",
},
"meta": {
"version": "v2",
"timestamp": datetime.now().isoformat(),
},
}
def handle_create_user(self, name: str, email: str = None) -> Dict:
return {
"data": {"id": 3, "name": name, "email": email},
"meta": {"version": "v2"},
}
Deprecation and Sunset Manager
# deprecation.py
from datetime import datetime, timezone, timedelta
from typing import Dict, Optional
class DeprecationManager:
def __init__(self):
self.versions: Dict[str, Dict] = {}
self.usage_log: list = []
def deprecate(self, version: str, sunset_days: int = 180,
successor: str = ""):
now = datetime.now(timezone.utc)
self.versions[version] = {
"deprecated_at": now,
"sunset": now + timedelta(days=sunset_days),
"successor": successor,
}
def get_headers(self, version: str) -> Dict[str, str]:
info = self.versions.get(version)
if not info:
return {}
headers = {
"Deprecation": info["deprecated_at"].strftime("%a, %d %b %Y %H:%M:%S GMT"),
"Sunset": info["sunset"].strftime("%a, %d %b %Y %H:%M:%S GMT"),
}
if info["successor"]:
headers["Link"] = f'<{info["successor"]}>; rel="successor-version"'
days = (info["sunset"] - datetime.now(timezone.utc)).days
if days < 30:
headers["Warning"] = f'299 - "Only {days} days until v{version} is removed"'
return headers
def log_usage(self, version: str, endpoint: str, client: str):
self.usage_log.append({
"version": version,
"endpoint": endpoint,
"client": client,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
def get_usage_summary(self) -> Dict[str, int]:
summary = {}
for entry in self.usage_log:
v = entry["version"]
summary[v] = summary.get(v, 0) + 1
return summary
Contract Testing
# contract_tests.py
from typing import Any, Callable, Dict, List
class ContractSuite:
def __init__(self):
self.tests: List[Dict] = []
def expect(self, name: str, handler: Callable, input_data: Dict,
expected_status: int = 200, expected_fields: List[str] = None):
self.tests.append({
"name": name,
"handler": handler,
"input": input_data,
"expected_status": expected_status,
"expected_fields": expected_fields or [],
})
def run(self) -> Dict[str, str]:
results = {}
for t in self.tests:
try:
response = t["handler"](**t["input"])
status = 200
for field in t["expected_fields"]:
parts = field.split(".")
current = response
for p in parts:
if isinstance(current, dict):
current = current.get(p)
else:
current = None
break
if current is None:
results[t["name"]] = f"FAIL: missing field '{field}'"
break
else:
results[t["name"]] = "PASS"
except Exception as e:
results[t["name"]] = f"ERROR: {e}"
return results
Main Application
# main.py
from typing import Any, Dict
from version_router import VersionRouter
from version_handlers import V1Handler, V2Handler
from deprecation import DeprecationManager
class VersionedAPI:
def __init__(self):
self.router = VersionRouter()
self.deprecation = DeprecationManager()
self.v1 = V1Handler()
self.v2 = V2Handler()
self.router.register(1, self._handle_v1)
self.router.register(2, self._handle_v2)
self.deprecation.deprecate("v1", sunset_days=180, successor="/docs/migrate-to-v2")
def _handle_v1(self, endpoint: str, **kwargs) -> Dict:
if endpoint == "/users":
return self.v1.handle_users()
return {"error": "not found", "version": "v1"}
def _handle_v2(self, endpoint: str, **kwargs) -> Dict:
if endpoint == "/users":
return self.v2.handle_users()
return {"error": "not found", "version": "v2"}
def handle_request(self, path: str, headers: Dict,
params: Dict = None) -> Dict:
version, clean_path, source = self.router.resolve(path, headers, params)
handler = self.router.handlers.get(version)
response = handler(clean_path)
if version == 1:
dep_headers = self.deprecation.get_headers("v1")
response["_deprecation_headers"] = dep_headers
return response
api = VersionedAPI()
tests = [
("/v1/users", {"Accept": "application/json"}, None),
("/v2/users", {"Accept": "application/json"}, None),
("/users", {"Accept": "application/vnd.myapp.v1+json"}, None),
("/users", {"X-API-Version": "2"}, None),
]
for path, headers, params in tests:
result = api.handle_request(path, headers, params)
version = result.get("version", result.get("meta", {}).get("version"))
warning = result.get("_deprecation_headers", {})
dep = "DEPRECATED" if warning else ""
print(f"{path:20s} -> v{version:5s} {dep}")
Expected output:
/v1/users -> v1 DEPRECATED
/v2/users -> v2
/users -> v1 DEPRECATED
/users -> v2
Running the Tests
# test_matrix.py
from contract_tests import ContractSuite
from version_handlers import V1Handler, V2Handler
v1 = V1Handler()
v2 = V2Handler()
suite = ContractSuite()
suite.expect("v1_get_users", v1.handle_users, {}, expected_fields=["users"])
suite.expect("v2_get_users", v2.handle_users, {}, expected_fields=["data", "meta"])
suite.expect("v2_create_user", v2.handle_create_user,
{"name": "Test", "email": "t@t.com"}, expected_fields=["data", "data.id"])
results = suite.run()
passed = sum(1 for v in results.values() if v == "PASS")
failed = sum(1 for v in results.values() if v != "PASS")
print(f"Contract tests: {passed} passed, {failed} failed")
for name, result in results.items():
print(f" {name:25s} {result}")
Expected output:
Contract tests: 3 passed, 0 failed
v1_get_users PASS
v2_get_users PASS
v2_create_user PASS
Project Summary
| Component | Purpose |
|---|---|
| VersionRouter | Multi-strategy version resolution (URI, Accept, X-API-Version, query) |
| V1Handler/V2Handler | Version-specific business logic |
| DeprecationManager | Track deprecation dates, return Sunset headers |
| ContractSuite | Validate that responses include expected fields per version |
| VersionedAPI | Main orchestrator combining routing, handlers, and deprecation |
Common Mistakes
1. Over-Engineering
Start with URI versioning. Add header or media type versioning only if needed. Keep it simple.
2. Not Testing Deprecated Versions
Deprecated does not mean untested. Run contract tests against all supported versions, including deprecated ones.
3. No Migration Metrics
Track which clients use which versions. Use this data to decide when sunset dates are appropriate.
4. Ignoring the Sunset Date
Collecting deprecated versions without enforcement creates infinite Technical Debt. Enforce sunset dates.
Practice Questions
1. What does the VersionRouter do when no version is specified?
It falls back to the default version (v1) and returns a response.
2. Why does v1 include deprecation headers?
To warn clients that v1 will eventually be removed and they should migrate to v2.
3. What happens if a client requests v3?
The router falls back to the latest available version (v2) and adds a warning.
4. How do contract tests prevent breaking changes?
They validate that each version's response includes the expected fields, catching regressions.
Challenge
Extend the project to support v3 with these changes:
- Rename
nametofull_namein user responses - Add
rolesfield with default["user"] - Mark v1 as sunset within 30 days
- Add contract tests for v3
FAQ
What's Next
You completed the API versioning project. Next, explore GraphQL vs REST to compare API paradigms, then learn about request validation.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro