Skip to content

API Versioning Mini Project — Complete Guide

DodaTech Updated 2026-06-28 8 min read

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 name to full_name in user responses
  • Add roles field with default ["user"]
  • Mark v1 as sunset within 30 days
  • Add contract tests for v3

FAQ

Is this project production-ready?

This is a reference implementation. Adapt it to your language, framework, and infrastructure. Key patterns are transferable.

How do I add a new version?

Create a new handler class (V3Handler), register it in VersionRouter, update tests, and deprecate the previous version.

Should I use a library for versioning?

Use framework middleware (e.g., Express versioning middleware, Django REST versioning) for production. Build custom logic only if needed.

How do I deploy multiple versions?

Containerize each version separately or use an API gateway (Kong, AWS API Gateway) to route by version.

How do I version the versioning system itself?

Keep the versioning infrastructure stable. Version your business APIs, not the versioning mechanism.

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