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

178 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 10:59 +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 

22from dataclasses import dataclass 

23from datetime import datetime, timedelta, timezone 

24from enum import Enum 

25from typing import Any, Callable, Dict, List, Optional, Tuple 

26 

27from starlette.requests import Request 

28from starlette.responses import JSONResponse, Response 

29 

30 

31# --------------------------------------------------------------------------- 

32# Semantic Version 

33# --------------------------------------------------------------------------- 

34 

35 

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

37class SemVer: 

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

39 major: int 

40 minor: int = 0 

41 patch: int = 0 

42 

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

44 

45 @classmethod 

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

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

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

49 if not m: 

50 return None 

51 return cls( 

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

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

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

55 ) 

56 

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

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

59 return self.major == other.major 

60 

61 def __str__(self) -> str: 

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

63 

64 

65# --------------------------------------------------------------------------- 

66# Version Extraction Strategy 

67# --------------------------------------------------------------------------- 

68 

69 

70class VersionStrategy(str, Enum): 

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

72 URL_PATH = "url_path" # /v1/resource 

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

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

75 

76 

77class VersionExtractor: 

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

79 

80 STRATEGIES: List[VersionStrategy] = [ 

81 VersionStrategy.URL_PATH, 

82 VersionStrategy.HEADER, 

83 VersionStrategy.QUERY_PARAM, 

84 ] 

85 

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

87 self._strategies = strategies or self.STRATEGIES 

88 

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

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

91 for strategy in self._strategies: 

92 version = self._try_strategy(request, strategy) 

93 if version is not None: 

94 return version 

95 return None 

96 

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

98 if strategy == VersionStrategy.URL_PATH: 

99 return self._from_url(request) 

100 elif strategy == VersionStrategy.HEADER: 

101 return self._from_header(request) 

102 elif strategy == VersionStrategy.QUERY_PARAM: 

103 return self._from_query(request) 

104 return None 

105 

106 @staticmethod 

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

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

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

110 parts = path.split("/") 

111 for i, part in enumerate(parts): 

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

113 return SemVer.parse(part) 

114 return None 

115 

116 @staticmethod 

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

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

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

120 if custom: 

121 return SemVer.parse(custom) 

122 

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

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

125 if version_match: 

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

127 return None 

128 

129 @staticmethod 

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

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

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

133 value = request.query_params.get(param) 

134 if value: 

135 return SemVer.parse(value) 

136 return None 

137 

138 

139# --------------------------------------------------------------------------- 

140# Deprecation Policy 

141# --------------------------------------------------------------------------- 

142 

143 

144@dataclass 

145class DeprecationInfo: 

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

147 version: SemVer 

148 sunset_date: Optional[datetime] = None 

149 migration_guide_url: Optional[str] = None 

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

151 

152 @property 

153 def is_sunset(self) -> bool: 

154 if self.sunset_date is None: 

155 return False 

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

157 

158 

159class DeprecationPolicy: 

160 """Manage API version deprecation.""" 

161 

162 def __init__(self): 

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

164 

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

166 """Mark a version as deprecated.""" 

167 semver = SemVer.parse(version) 

168 if semver is None: 

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

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

171 self._deprecated[semver] = DeprecationInfo( 

172 version=semver, 

173 sunset_date=sunset, 

174 **kwargs, 

175 ) 

176 

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

178 return version in self._deprecated 

179 

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

181 return self._deprecated.get(version) 

182 

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

184 info = self._deprecated.get(version) 

185 return info is not None and info.is_sunset 

186 

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

188 return { 

189 str(v): { 

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

191 "is_sunset": d.is_sunset, 

192 "migration_guide": d.migration_guide_url, 

193 } 

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

195 } 

196 

197 

198# --------------------------------------------------------------------------- 

199# Versioned Router 

200# --------------------------------------------------------------------------- 

201 

202 

203class VersionedRouter: 

204 """ 

205 Route requests to version-specific handlers. 

206 

207 Supports semantic version negotiation: 

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

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

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

211 """ 

212 

213 def __init__(self): 

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

215 self._default_version: Optional[SemVer] = None 

216 

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

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

219 semver = SemVer.parse(version) 

220 if semver is None: 

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

222 self._handlers[semver] = handler 

223 

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

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

226 self._default_version = SemVer.parse(version) 

227 

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

229 """ 

230 Resolve a version to its handler. 

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

232 """ 

233 # Exact match 

234 if requested in self._handlers: 

235 return self._handlers[requested], requested 

236 

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

238 candidates = [ 

239 v for v in self._handlers 

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

241 ] 

242 if candidates: 

243 best = max(candidates) # highest compatible version 

244 return self._handlers[best], best 

245 

246 # No match → default or None 

247 if self._default_version: 

248 default = self._default_version 

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

250 

251 return None, None 

252 

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

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

255 

256 

257# --------------------------------------------------------------------------- 

258# Starlette Middleware 

259# --------------------------------------------------------------------------- 

260 

261 

262class APIVersioningMiddleware: 

263 """ 

264 Starlette-compatible API versioning middleware. 

265 

266 Usage: 

267 app = Starlette() 

268 app.add_middleware( 

269 APIVersioningMiddleware, 

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

271 default_version="v1", 

272 ) 

273 """ 

274 

275 def __init__( 

276 self, 

277 app, 

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

279 default_version: Optional[str] = None, 

280 deprecation_policy: Optional[DeprecationPolicy] = None, 

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

282 ): 

283 self.app = app 

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

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

286 self._deprecation = deprecation_policy or DeprecationPolicy() 

287 self._extractor = VersionExtractor(strategies) 

288 

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

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

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

292 return 

293 

294 request = Request(scope, receive) 

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

296 

297 # No version → pass through 

298 if version is None: 

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

300 return 

301 

302 # Check if version is supported 

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

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

305 await response(scope, receive, send) 

306 return 

307 

308 # Check deprecation → add warning header 

309 if self._deprecation.should_block(version): 

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

311 await response(scope, receive, send) 

312 return 

313 

314 # Add version to request state 

315 request.state.api_version = version 

316 request.state.api_version_str = str(version) 

317 

318 # Add deprecation warning header 

319 if self._deprecation.is_deprecated(version): 

320 info = self._deprecation.get_info(version) 

321 if info: 

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

323 return 

324 

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

326 

327 async def _with_deprecation_warning( 

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

329 ) -> None: 

330 """Wrap response with deprecation headers.""" 

331 

332 async def send_wrapper(message): 

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

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

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

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

337 if info.migration_guide_url: 

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

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

340 await send(message) 

341 

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

343 

344 @staticmethod 

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

346 if reason == "unsupported": 

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

348 status_code = 400 

349 else: 

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

351 status_code = 410 

352 

353 return JSONResponse( 

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

355 status_code=status_code, 

356 ) 

357 

358 

359# --------------------------------------------------------------------------- 

360# Backward Compatibility Aliases 

361# --------------------------------------------------------------------------- 

362 

363# Old API → New API mapping 

364APIVersion = SemVer # SemVer replaces APIVersion 

365VersionConfig = DeprecationPolicy # DeprecationPolicy replaces VersionConfig 

366VersionNegotiator = VersionedRouter # VersionedRouter replaces VersionNegotiator