executing-plans
executing-plans
Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available
rest-api-versioning-strategiesImplements API versioning strategies including URL path versioning, Accept header media type versioning, deprecation headers with Sunset and Deprecation, and backward-compatible contract evolution for REST APIs.
Independent GitHub snapshot · Automated classification · Original documentation governs
name: rest-api-versioning-strategies description: Implements API versioning strategies including URL path versioning, Accept header media type versioning, deprecation headers with Sunset and Deprecation, and backward-compatible contract evolution for REST APIs. license: MIT compatibility: opencode metadata: version: "1.0.0" domain: coding triggers: API versioning, URL path versioning, Accept header versioning, API deprecation, Sunset header, REST API backward compatibility, how do i version an API, breaking changes API role: implementation scope: implementation output-format: code content-types: [code, guidance, do-dont, examples] archetypes: - tactical - strategic anti_triggers: - brainstorming - vague ideation - code golf response_profile: verbosity: low directive_strength: high abstraction_level: operational related-skills: rest-api-resource-modeling, rest-api-pagination-filtering
Designs and implements backward-compatible REST API versioning strategies including URL path versioning for public APIs, Accept header versioning for machine clients, deprecation headers with Sunset dates, and migration paths between versions. When active, the model selects the appropriate versioning strategy based on the API's audience, isolates version-specific route modules, adds deprecation signaling on responses, and enforces the golden rule of backward-compatible contract evolution.
/api/v1/ and /api/v2/ prefixesUse this skill when:
Avoid this skill for:
Choose Versioning Strategy Based on Audience — For public APIs consumed by third parties: use URL path versioning (/api/v1/resource) for maximum discoverability and client clarity. For machine-to-machine APIs where clients can dynamically negotiate content types: use Accept header versioning (Accept: application/vnd.myapi.v2+json).
Checkpoint: If external developers or mobile apps consume the API, use URL path versioning — it's the most universally supported approach across HTTP clients, SDKs, and API gateways.
Implement Router-Level Isolation Between Versions — Create separate route modules per major version. Each version gets its own FastAPI router with a unique path prefix (/api/v1, /api/v2). Route handlers in each version are independent — changes to v2 must never affect v1 behavior.
Checkpoint: Every endpoint accessible at /api/v1/X must return the same shape and data regardless of what exists at /api/v2/X.
Add Deprecation Headers When Releasing a New Major Version — When launching v2, add Deprecation: true and Sunset: <date> headers to every v1 response. The Sunset date must be at least 6 months in the future to give consumers adequate migration time.
Checkpoint: Every deprecated version's responses must include both Deprecation and Sunset headers — missing either breaks the deprecation signaling contract.
Enforce the Golden Rule: New Fields Are Optional — When adding new fields to response objects, they MUST be optional in at least one full version cycle. Never remove or rename a field without first making it deprecated (still present but undocumented) for a full version cycle, then removing it in the next major release. Checkpoint: No client should ever fail because of an additional field in a response body. Fields can be added freely; removal requires a major version bump.
Document Migration Paths Between Versions — Create a migration guide listing every breaking change between versions: removed fields, renamed fields, changed data types, altered status codes, new required parameters. Each migration must include the old behavior and the corresponding new behavior with code examples. Checkpoint: A consumer upgrading from v1 to v2 should be able to update their integration using only the migration guide — no trial-and-error debugging required.
URL path versioning embeds the API version in the URI (/api/v1/users, /api/v2/users). Each version has its own router module, ensuring complete isolation between versions — changes to v2 cannot affect v1 behavior.
# ❌ BAD: Single router with breaking changes mixed into one endpoint
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
def get_user_broken(user_id: str):
"""Mixes v1 and v2 logic in one handler — no version isolation."""
# If a client wants v1 behavior but gets v2 response, there's no way to opt out.
# Breaking changes (new required fields, renamed properties) silently break existing clients.
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com",
"created_at": "2025-01-15T10:30:00Z", # New in v2 — breaks clients expecting only id/name
"address": {"street": "123 Main St", "city": "Springfield"}, # Nested object added
"_links": { # HATEOAS links added in v2 — not present in v1
"self": f"/users/{user_id}",
"orders": f"/users/{user_id}/orders",
},
}
# ❌ BAD: Version as query parameter — hard to cache, inconsistent routing
@app.get("/users")
def get_users_query_param(version: str = "v1"):
"""Query param versioning is fragile — proxies may drop unknown params."""
if version == "v2":
return {"data": [], "_meta": {"api_version": "v2"}}
return {"data": []}
# ✅ GOOD: Separate routers per version with path prefix isolation
from fastapi import FastAPI, APIRouter
app = FastAPI(title="My API", version="1.0.0")
# --- V1 Router (current stable) ---
v1_router = APIRouter(prefix="/api/v1", tags=["users-v1"])
@v1_router.get("/users/{user_id}", summary="Get user by ID (v1)")
def get_user_v1(user_id: str):
"""GET /api/v1/users/{user_id} — V1 response shape.
Contains only fields defined in the v1 specification.
No HATEOAS links, no nested objects beyond what was in v1.
Clients consuming v1 should never see additional fields from v2.
"""
# In production: user = db.get_user(user_id)
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com",
}
@v1_router.get("/users", summary="List users (v1)")
def list_users_v1():
"""GET /api/v1/users — V1 collection endpoint."""
return {"items": []}
# --- V2 Router (new version, v1 still active) ---
v2_router = APIRouter(prefix="/api/v2", tags=["users-v2"])
@v2_router.get("/users/{user_id}", summary="Get user by ID (v2)")
def get_user_v2(user_id: str):
"""GET /api/v2/users/{user_id} — V2 response shape with new fields.
Adds HATEOAS links, address field, and created_at timestamp.
These are additional fields — v1 clients remain unaffected because they
access /api/v1/..., not /api/v2/.
"""
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com",
"created_at": "2025-01-15T10:30:00Z",
"address": {"street": "123 Main St", "city": "Springfield"},
"_links": {
"self": f"/api/v2/users/{user_id}",
"orders": f"/api/v2/users/{user_id}/orders",
},
}
@v2_router.get("/users", summary="List users (v2)")
def list_users_v2():
"""GET /api/v2/users — V2 collection endpoint with pagination."""
return {"items": [], "_links": {"self": "/api/v2/users"}}
# --- Mount both routers on the main app ---
app.include_router(v1_router)
app.include_router(v2_router)
# Both endpoints are accessible:
# GET /api/v1/users/550e8400 → v1 shape (id, name, email)
# GET /api/v2/users/550e8400 → v2 shape (id, name, email, created_at, address, _links)
Accept header versioning uses vendor-specific media types (application/vnd.myapi.v1+json) to negotiate the response format. This keeps URIs clean and purely resource-based but requires clients to understand HTTP content negotiation. Best suited for machine-to-machine APIs where clients are well-maintained SDKs or internal services.
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import re
app = FastAPI(title="My API (Accept Header Versioning)", version="1.0.0")
# --- Content negotiation middleware for Accept header versioning ---
def parse_accept_version(accept_header: str) -> str:
"""Parse the API version from the Accept header.
Supports vendor-specific media types like:
- application/vnd.myapi.v1+json → v1
- application/vnd.myapi.v2+json → v2
- application/json → defaults to latest stable (v1)
Args:
accept_header: The value of the Accept request header.
Returns:
Version string like 'v1', 'v2', or None for default.
"""
# Match vendor-specific API version in Accept header
match = re.search(r'application/vnd\.myapi\.v(\d+)\+json', accept_header)
if match:
return f"v{match.group(1)}"
# Default to v1 for standard JSON clients
if "application/json" in accept_header:
return "v1"
# Unknown content type — reject
return None
def build_response_for_version(data: dict, version: str) -> dict:
"""Return the response shaped for the requested API version.
V1 returns the minimal shape (id, name, email).
V2 adds HATEOAS links and address information.
Each additional field is additive — never removing or renaming fields between versions.
"""
base = {
"id": data["id"],
"name": data["name"],
"email": data["email"],
}
if version == "v2":
base["created_at"] = data.get("created_at")
base["address"] = data.get("address")
base["_links"] = {
"self": f"/api/users/{data['id']}",
"orders": f"/api/users/{data['id']}/orders",
}
return base
# --- Middleware that applies Accept header versioning ---
@app.middleware("http")
async def accept_version_middleware(request: Request, call_next):
"""Intercept every request to determine API version from Accept header.
Routes the response through version-shaping logic before sending.
Returns 406 Not Acceptable if the client requests an unsupported version.
"""
accept_header = request.headers.get("accept", "application/json")
version = parse_accept_version(accept_header)
if version is None:
return JSONResponse(
status_code=406,
content={
"type": "https://api.example.com/errors/not-acceptable",
"title": "Not Acceptable",
"status": 406,
"detail": (
"The Accept header must include a supported API version. "
"Use 'application/vnd.myapi.v1+json' or 'application/vnd.myapi.v2+json'."
),
},
)
# Process the request normally
response = await call_next(request)
# Shape the response body according to the negotiated version
if response.status_code == 200 and response.headers.get("content-type") == "application/json":
body_content = b""
async for chunk in response.body_iterator:
body_content += chunk
data = __import__("json").loads(body_content)
shaped_data = build_response_for_version(data, version)
# Return new response with versioned shape
return JSONResponse(
content=shaped_data,
status_code=response.status_code,
headers={k: v for k, v in response.headers.items() if k.lower() != "content-length"},
)
return response
# --- Version-agnostic route handlers (data fetching only) ---
@app.get("/users/{user_id}")
def get_user_agnostic(user_id: str):
"""Fetch user data without version shaping — middleware handles versioning.
The handler returns the complete data set. Middleware reshapes it
to the client's negotiated version before sending the response.
"""
# In production: query database for full user record
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com",
"created_at": "2025-01-15T10:30:00Z",
"address": {"street": "123 Main St", "city": "Springfield"},
}
# --- Usage example ---
# Client A (v1 consumer):
# GET /users/550e8400
# Accept: application/json → receives {id, name, email}
#
# Client B (v2 consumer):
# GET /users/550e8400
# Accept: application/vnd.myapi.v2+json → receives {id, name, email, created_at, address, _links}
#
# Client C (unsupported version):
# GET /users/550e8400
# Accept: application/vnd.myapi.v3+json → receives 406 Not Acceptable
When a new major version is released, the old version must be deprecated with proper HTTP headers that inform consumers when to migrate and by when. The Deprecation header signals intent; the Sunset header provides a concrete deadline; the Link header points to migration documentation.
# ❌ BAD: No deprecation notice — consumers have no warning before the API disappears
from fastapi import FastAPI, Response
app = FastAPI()
@app.get("/users/{user_id}")
def get_user_no_deprecation(user_id: str):
"""V1 endpoint with no deprecation headers — existing clients won't know when to migrate."""
return {"id": user_id, "name": "Alice"}
# This will break silently when the endpoint is removed without warning.
# Mobile apps with 6-week update cycles may have broken installations.
# Web consumers caching API specs won't discover the migration path.
# ✅ GOOD: Deprecation headers on every v1 response with migration documentation
from fastapi import FastAPI, Response
from datetime import datetime, timezone, timedelta
import re
app = FastAPI()
# Configuration for deprecated versions
DEPRECATED_VERSIONS = {
"v1": {
"deprecation_date": datetime(2026, 1, 1, tzinfo=timezone.utc),
"sunset_date": datetime(2026, 7, 1, tzinfo=timezone.utc), # 6-month minimum notice
"migration_url": "https://docs.example.com/migration/v1-to-v2",
},
}
def add_deprecation_headers(response: Response, path: str) -> None:
"""Add deprecation headers to responses from deprecated API versions.
Sets Deprecation: true, Sunset date in HTTP-date format, and Link to migration docs.
Only applies if the request path matches a deprecated version prefix.
Per RFC 8594, the Sunset header uses the HTTP-date format (RFC 7231 §7.1.1.2).
"""
# Check if this request targets a deprecated version
for version, config in DEPRECATED_VERSIONS.items():
prefix = f"/api/{version}"
if path.startswith(prefix):
response.headers["Deprecation"] = "true"
# Sunset date in HTTP-date format: Sun, 01 Jun 2026 00:00:00 GMT
sunset_str = config["sunset_date"].strftime("%a, %d %b %Y %H:%M:%S GMT")
response.headers["Sunset"] = sunset_str
# Link header pointing to migration documentation (RFC 5988 format)
response.headers["Link"] = (
f'<{config["migration_url"]}>; rel="deprecation"; '
f'title="Migration guide from {version} to v2"'
)
return
@app.middleware("http")
async def deprecation_middleware(request: Request, call_next):
"""Add deprecation headers to every response for deprecated API versions."""
response = await call_next(request)
# Only add headers to successful responses from deprecated versions
if 200 <= response.status_code < 300:
add_deprecation_headers(response, request.url.path)
return response
# --- V1 endpoint (deprecated) with automatic deprecation headers ---
@app.get("/api/v1/users/{user_id}")
def get_user_v1_deprecated(user_id: str):
"""GET /api/v1/users/{user_id} — Deprecated v1 endpoint.
Still functional but will be removed on the Sunset date.
Clients see Deprecation: true, Sunset, and Link headers in every response.
"""
return {"id": user_id, "name": "Alice"}
@app.get("/api/v1/users")
def list_users_v1_deprecated():
"""GET /api/v1/users — Deprecated v1 endpoint."""
return {"items": []}
# --- V2 endpoint (current, no deprecation headers) ---
@app.get("/api/v2/users/{user_id}")
def get_user_v2(user_id: str):
"""GET /api/v2/users/{user_id} — Current stable version.
No deprecation headers because this is the active version.
"""
return {
"id": user_id,
"name": "Alice",
"email": "alice@example.com",
"_links": {"self": f"/api/v2/users/{user_id}"},
}
# --- Example response headers for a v1 request ---
"""
GET /api/v1/users/550e8400 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sun, 01 Jun 2026 00:00:00 GMT
Link: <https://docs.example.com/migration/v1-to-v2>; rel="deprecation"; title="Migration guide from v1 to v2"
{"id": "550e8400", "name": "Alice"}
"""
# --- Example response headers for a v2 request (no deprecation) ---
"""
GET /api/v2/users/550e8400 HTTP/1.1
Host: api.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{"id": "550e8400", "name": "Alice", "email": "alice@example.com"}
"""
/api/v1, /api/v2). Changes to one version must never affect another.Deprecation: true, Sunset (HTTP-date format, at least 6 months in the future), and Link to migration documentation for every v1 response after v2 launches.if version == "v2":) create maintenance debt and risk of inconsistency between versions./users?api_version=v2) — proxies, CDNs, and API gateways may drop or cache these differently across versions, causing unpredictable behavior.When implementing or reviewing API versioning with this skill active, produce:
Versioning Strategy Decision — State the chosen strategy (URL path vs. Accept header) with justification based on the API's audience and client capabilities.
Router Isolation Map — List every router module, its path prefix, and which version of each endpoint it serves:
| Router | Prefix | Endpoints |
|---|---|---|
v1_router | /api/v1/ | GET /users/{id}, GET /users |
v2_router | /api/v2/ | GET /users/{id} (with _links), GET /users, POST /users |
Deprecation Header Configuration — For each deprecated version: deprecation date, Sunset date (HTTP-date format), and migration documentation URL.
Migration Guide Skeleton — Outline of breaking changes between versions with before/after examples for each field change.
| Skill | Purpose |
|---|---|
rest-api-resource-modeling | The resource model that is being versioned — versioning decisions should consider the URI structure and HATEOAS link stability across versions |
rest-api-pagination-filtering | Pagination and filtering may differ between API versions; each version needs its own pagination envelope specification |
Authoritative documentation links for API versioning strategies. The model follows these references at load time to resolve external content.
📖 skill(local cache): rest-api-versioning-strategies