Coverage for agentos/api/versioning.py: 36%

179 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 08:01 +0800

1""" 

2AgentOS API Versioning — Semantic Versioning Middleware & Router 

3━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 

4 

5Production-grade API versioning with: 

6 - URL path versioning (default): /v1/resource, /v2/resource 

7 - Header-based versioning: Accept: application/json; version=1 

8 - Query parameter versioning: /resource?api_version=1 

9 - Semantic version negotiation (closest match) 

10 - Deprecation notices with sunset dates 

11 - Automatic OpenAPI versioned docs 

12 

13Architecture: 

14 VersioningMiddleware → extract version from request 

15 VersionedRouter → route to correct version handler 

16 DeprecationPolicy → warn/block deprecated versions 

17""" 

18 

19from __future__ import annotations 

20 

21import re 

22import time 

23from dataclasses import dataclass, field 

24from datetime import datetime, timedelta, timezone 

25from enum import Enum 

26from typing import Any, Callable, Dict, List, Optional, Set, Tuple 

27 

28from starlette.requests import Request 

29from starlette.responses import JSONResponse, Response 

30 

31 

32# --------------------------------------------------------------------------- 

33# Semantic Version 

34# --------------------------------------------------------------------------- 

35 

36 

37@dataclass(order=True, frozen=True) 

38class SemVer: 

39 """Semantic version (major.minor.patch).""" 

40 major: int 

41 minor: int = 0 

42 patch: int = 0 

43 

44 _PARSE_RE = re.compile(r"^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?") 

45 

46 @classmethod 

47 def parse(cls, version_str: str) -> Optional["SemVer"]: 

48 """Parse a version string like 'v1', '2.0', '1.2.3'.""" 

49 m = cls._PARSE_RE.match(version_str.strip()) 

50 if not m: 

51 return None 

52 return cls( 

53 major=int(m.group(1)), 

54 minor=int(m.group(2) or 0), 

55 patch=int(m.group(3) or 0), 

56 ) 

57 

58 def is_compatible(self, other: "SemVer") -> bool: 

59 """Check if other version is API-compatible (same major).""" 

60 return self.major == other.major 

61 

62 def __str__(self) -> str: 

63 return f"v{self.major}.{self.minor}.{self.patch}" 

64 

65 

66# --------------------------------------------------------------------------- 

67# Version Extraction Strategy 

68# --------------------------------------------------------------------------- 

69 

70 

71class VersionStrategy(str, Enum): 

72 """How to extract API version from requests.""" 

73 URL_PATH = "url_path" # /v1/resource 

74 HEADER = "header" # Accept: application/json; version=1 

75 QUERY_PARAM = "query_param" # /resource?api_version=1 

76 

77 

78class VersionExtractor: 

79 """Extract API version from incoming requests.""" 

80 

81 STRATEGIES: List[VersionStrategy] = [ 

82 VersionStrategy.URL_PATH, 

83 VersionStrategy.HEADER, 

84 VersionStrategy.QUERY_PARAM, 

85 ] 

86 

87 def __init__(self, strategies: Optional[List[VersionStrategy]] = None): 

88 self._strategies = strategies or self.STRATEGIES 

89 

90 def extract(self, request: Request) -> Optional[SemVer]: 

91 """Try each strategy in order; return first match.""" 

92 for strategy in self._strategies: 

93 version = self._try_strategy(request, strategy) 

94 if version is not None: 

95 return version 

96 return None 

97 

98 def _try_strategy(self, request: Request, strategy: VersionStrategy) -> Optional[SemVer]: 

99 if strategy == VersionStrategy.URL_PATH: 

100 return self._from_url(request) 

101 elif strategy == VersionStrategy.HEADER: 

102 return self._from_header(request) 

103 elif strategy == VersionStrategy.QUERY_PARAM: 

104 return self._from_query(request) 

105 return None 

106 

107 @staticmethod 

108 def _from_url(request: Request) -> Optional[SemVer]: 

109 """Extract from /v{major}/... or /v{major}.{minor}/...""" 

110 path = request.url.path.lstrip("/") 

111 parts = path.split("/") 

112 for i, part in enumerate(parts): 

113 if part.lower().startswith("v") and part[1:].replace(".", "").isdigit(): 

114 return SemVer.parse(part) 

115 return None 

116 

117 @staticmethod 

118 def _from_header(request: Request) -> Optional[SemVer]: 

119 """Extract from Accept header or custom X-API-Version.""" 

120 custom = request.headers.get("X-API-Version") 

121 if custom: 

122 return SemVer.parse(custom) 

123 

124 accept = request.headers.get("Accept", "") 

125 version_match = re.search(r"version=(\d+(?:\.\d+)*)", accept) 

126 if version_match: 

127 return SemVer.parse(version_match.group(1)) 

128 return None 

129 

130 @staticmethod 

131 def _from_query(request: Request) -> Optional[SemVer]: 

132 """Extract from ?api_version=1 or ?v=2.0.""" 

133 for param in ("api_version", "v", "version"): 

134 value = request.query_params.get(param) 

135 if value: 

136 return SemVer.parse(value) 

137 return None 

138 

139 

140# --------------------------------------------------------------------------- 

141# Deprecation Policy 

142# --------------------------------------------------------------------------- 

143 

144 

145@dataclass 

146class DeprecationInfo: 

147 """Information about a deprecated API version.""" 

148 version: SemVer 

149 sunset_date: Optional[datetime] = None 

150 migration_guide_url: Optional[str] = None 

151 message: str = "This API version is deprecated." 

152 

153 @property 

154 def is_sunset(self) -> bool: 

155 if self.sunset_date is None: 

156 return False 

157 return datetime.now(timezone.utc) > self.sunset_date 

158 

159 

160class DeprecationPolicy: 

161 """Manage API version deprecation.""" 

162 

163 def __init__(self): 

164 self._deprecated: Dict[SemVer, DeprecationInfo] = {} 

165 

166 def deprecate(self, version: str, sunset_days: int = 90, **kwargs) -> None: 

167 """Mark a version as deprecated.""" 

168 semver = SemVer.parse(version) 

169 if semver is None: 

170 raise ValueError(f"Invalid version: {version}") 

171 sunset = datetime.now(timezone.utc) + timedelta(days=sunset_days) 

172 self._deprecated[semver] = DeprecationInfo( 

173 version=semver, 

174 sunset_date=sunset, 

175 **kwargs, 

176 ) 

177 

178 def is_deprecated(self, version: SemVer) -> bool: 

179 return version in self._deprecated 

180 

181 def get_info(self, version: SemVer) -> Optional[DeprecationInfo]: 

182 return self._deprecated.get(version) 

183 

184 def should_block(self, version: SemVer) -> bool: 

185 info = self._deprecated.get(version) 

186 return info is not None and info.is_sunset 

187 

188 def list_deprecated(self) -> Dict[str, Dict[str, Any]]: 

189 return { 

190 str(v): { 

191 "sunset_date": d.sunset_date.isoformat() if d.sunset_date else None, 

192 "is_sunset": d.is_sunset, 

193 "migration_guide": d.migration_guide_url, 

194 } 

195 for v, d in self._deprecated.items() 

196 } 

197 

198 

199# --------------------------------------------------------------------------- 

200# Versioned Router 

201# --------------------------------------------------------------------------- 

202 

203 

204class VersionedRouter: 

205 """ 

206 Route requests to version-specific handlers. 

207 

208 Supports semantic version negotiation: 

209 - Exact match: /v1.0.0 → v1.0.0 handler 

210 - Minor fallback: /v1.2.x → v1.2.0 handler 

211 - Major fallback: /v1.x.x → latest v1 handler 

212 """ 

213 

214 def __init__(self): 

215 self._handlers: Dict[SemVer, Callable] = {} 

216 self._default_version: Optional[SemVer] = None 

217 

218 def register(self, version: str, handler: Callable) -> None: 

219 """Register a handler for a specific version.""" 

220 semver = SemVer.parse(version) 

221 if semver is None: 

222 raise ValueError(f"Invalid version: {version}") 

223 self._handlers[semver] = handler 

224 

225 def set_default(self, version: str) -> None: 

226 """Set the default version when no version is specified.""" 

227 self._default_version = SemVer.parse(version) 

228 

229 def resolve(self, requested: SemVer) -> Tuple[Optional[Callable], Optional[SemVer]]: 

230 """ 

231 Resolve a version to its handler. 

232 Returns (handler, actual_version) or (None, None). 

233 """ 

234 # Exact match 

235 if requested in self._handlers: 

236 return self._handlers[requested], requested 

237 

238 # Minor fallback: within same major, find closest <= requested 

239 candidates = [ 

240 v for v in self._handlers 

241 if v.major == requested.major and v <= requested 

242 ] 

243 if candidates: 

244 best = max(candidates) # highest compatible version 

245 return self._handlers[best], best 

246 

247 # No match → default or None 

248 if self._default_version: 

249 default = self._default_version 

250 return self._handlers.get(default), default 

251 

252 return None, None 

253 

254 def list_versions(self) -> List[str]: 

255 return sorted(str(v) for v in self._handlers.keys()) 

256 

257 

258# --------------------------------------------------------------------------- 

259# Starlette Middleware 

260# --------------------------------------------------------------------------- 

261 

262 

263class APIVersioningMiddleware: 

264 """ 

265 Starlette-compatible API versioning middleware. 

266 

267 Usage: 

268 app = Starlette() 

269 app.add_middleware( 

270 APIVersioningMiddleware, 

271 supported_versions=["v1", "v2"], 

272 default_version="v1", 

273 ) 

274 """ 

275 

276 def __init__( 

277 self, 

278 app, 

279 supported_versions: Optional[List[str]] = None, 

280 default_version: Optional[str] = None, 

281 deprecation_policy: Optional[DeprecationPolicy] = None, 

282 strategies: Optional[List[VersionStrategy]] = None, 

283 ): 

284 self.app = app 

285 self._supported = {SemVer.parse(v) for v in (supported_versions or []) if SemVer.parse(v)} 

286 self._default = SemVer.parse(default_version) if default_version else None 

287 self._deprecation = deprecation_policy or DeprecationPolicy() 

288 self._extractor = VersionExtractor(strategies) 

289 

290 async def __call__(self, scope, receive, send) -> None: 

291 if scope["type"] != "http": 

292 await self.app(scope, receive, send) 

293 return 

294 

295 request = Request(scope, receive) 

296 version = self._extractor.extract(request) or self._default 

297 

298 # No version → pass through 

299 if version is None: 

300 await self.app(scope, receive, send) 

301 return 

302 

303 # Check if version is supported 

304 if self._supported and version not in self._supported: 

305 response = self._version_error_response(version, "unsupported") 

306 await response(scope, receive, send) 

307 return 

308 

309 # Check deprecation → add warning header 

310 if self._deprecation.should_block(version): 

311 response = self._version_error_response(version, "sunset") 

312 await response(scope, receive, send) 

313 return 

314 

315 # Add version to request state 

316 request.state.api_version = version 

317 request.state.api_version_str = str(version) 

318 

319 # Add deprecation warning header 

320 if self._deprecation.is_deprecated(version): 

321 info = self._deprecation.get_info(version) 

322 if info: 

323 response = await self._with_deprecation_warning(scope, receive, send, info) 

324 return 

325 

326 await self.app(scope, receive, send) 

327 

328 async def _with_deprecation_warning( 

329 self, scope, receive, send, info: DeprecationInfo 

330 ) -> None: 

331 """Wrap response with deprecation headers.""" 

332 

333 async def send_wrapper(message): 

334 if message["type"] == "http.response.start": 

335 headers = dict(message.get("headers", [])) 

336 headers[b"deprecation"] = b"true" 

337 headers[b"sunset"] = info.sunset_date.isoformat().encode() if info.sunset_date else b"unknown" 

338 if info.migration_guide_url: 

339 headers[b"link"] = f'<{info.migration_guide_url}>; rel="deprecation"'.encode() 

340 message["headers"] = list(headers.items()) 

341 await send(message) 

342 

343 await self.app(scope, receive, send_wrapper) 

344 

345 @staticmethod 

346 def _version_error_response(version: SemVer, reason: str) -> Response: 

347 if reason == "unsupported": 

348 detail = f"API version {version} is not supported." 

349 status_code = 400 

350 else: 

351 detail = f"API version {version} has been sunset." 

352 status_code = 410 

353 

354 return JSONResponse( 

355 {"error": reason, "detail": detail, "version": str(version)}, 

356 status_code=status_code, 

357 ) 

358 

359 

360# --------------------------------------------------------------------------- 

361# Backward Compatibility Aliases 

362# --------------------------------------------------------------------------- 

363 

364# Old API → New API mapping 

365APIVersion = SemVer # SemVer replaces APIVersion 

366VersionConfig = DeprecationPolicy # DeprecationPolicy replaces VersionConfig 

367VersionNegotiator = VersionedRouter # VersionedRouter replaces VersionNegotiator