Coverage for src/lexigram/web/serialization/serializers.py: 26%

137 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""Response serializers for different media types.""" 

2 

3from __future__ import annotations 

4 

5from abc import ABC, abstractmethod 

6from typing import TYPE_CHECKING, Any 

7 

8from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response 

9 

10if TYPE_CHECKING: 

11 from starlette.requests import Request as StarletteRequest 

12 

13 from lexigram.contracts.events import CommandBusProtocol, QueryBusProtocol 

14 from lexigram.contracts.mapping import ObjectMapperProtocol 

15 

16 

17class AbstractMediaSerializer(ABC): 

18 """Protocol for media-specific response serializers.""" 

19 

20 @abstractmethod 

21 def supports(self, media_type: str) -> bool: 

22 """Check if this serializer supports the given media type.""" 

23 ... 

24 

25 @abstractmethod 

26 def supported_types(self) -> list[str]: 

27 """Return list of supported media types.""" 

28 ... 

29 

30 @abstractmethod 

31 async def serialize( 

32 self, 

33 data: Any, 

34 request: StarletteRequest, 

35 status_code: int = 200, 

36 ) -> Response: 

37 """Serialize data into a Response.""" 

38 ... 

39 

40 

41class JSONSerializer(AbstractMediaSerializer): 

42 """Serializes responses as JSON using high-performance core utilities.""" 

43 

44 def supports(self, media_type: str) -> bool: 

45 return "application/json" in media_type 

46 

47 def supported_types(self) -> list[str]: 

48 return ["application/json"] 

49 

50 async def serialize( 

51 self, 

52 data: Any, 

53 request: StarletteRequest, 

54 status_code: int = 200, 

55 ) -> JSONResponse: 

56 from lexigram.web.transport.responses import JSONResponse 

57 

58 # lexigram.web.transport.responses.JSONResponse handles high-performance 

59 # serialization (orjson/Pydantic) via core lexigram.serialization. 

60 return JSONResponse(content=data, status_code=status_code) # type: ignore[return-value] 

61 

62 

63class HTMLSerializer(AbstractMediaSerializer): 

64 """Serializes responses as HTML.""" 

65 

66 def supports(self, media_type: str) -> bool: 

67 return "text/html" in media_type 

68 

69 def supported_types(self) -> list[str]: 

70 return ["text/html"] 

71 

72 async def serialize( 

73 self, 

74 data: Any, 

75 request: StarletteRequest, 

76 status_code: int = 200, 

77 ) -> HTMLResponse: 

78 from lexigram.web.transport.responses import HTMLResponse 

79 

80 # Convert to string if not already 

81 if not isinstance(data, str): 

82 data = str(data) 

83 

84 return HTMLResponse(content=data, status_code=status_code) # type: ignore[return-value] 

85 

86 

87class PlainTextSerializer(AbstractMediaSerializer): 

88 """Serializes responses as plain text.""" 

89 

90 def supports(self, media_type: str) -> bool: 

91 return "text/plain" in media_type 

92 

93 def supported_types(self) -> list[str]: 

94 return ["text/plain"] 

95 

96 async def serialize( 

97 self, 

98 data: Any, 

99 request: StarletteRequest, 

100 status_code: int = 200, 

101 ) -> PlainTextResponse: 

102 # Convert to string if not already 

103 if not isinstance(data, str): 

104 data = str(data) 

105 

106 return PlainTextResponse(content=data, status_code=status_code) 

107 

108 

109class XMLSerializer(AbstractMediaSerializer): 

110 """Serializes responses as XML.""" 

111 

112 def supports(self, media_type: str) -> bool: 

113 return "application/xml" in media_type or "text/xml" in media_type 

114 

115 def supported_types(self) -> list[str]: 

116 return ["application/xml", "text/xml"] 

117 

118 async def serialize( 

119 self, 

120 data: Any, 

121 request: StarletteRequest, 

122 status_code: int = 200, 

123 ) -> Response: 

124 # Convert to string if not already 

125 if not isinstance(data, str): 

126 data = str(data) 

127 

128 return Response( 

129 content=data, status_code=status_code, media_type="application/xml" 

130 ) 

131 

132 

133class ResponseSerializer: 

134 """High-level orchestrator for response serialization. 

135 

136 Handles: 

137 1. Result types (Ok/Err) 

138 2. CQRS Messages (Command/Query) 

139 3. ObjectMapper transformations 

140 4. Media-type specific serialization 

141 

142 Dependencies are injected via constructor to avoid service locator pattern. 

143 """ 

144 

145 _registry: dict[str, AbstractMediaSerializer] = {} 

146 _default_serializer: AbstractMediaSerializer = JSONSerializer() 

147 

148 def __init__( 

149 self, 

150 command_bus: CommandBusProtocol | None = None, 

151 query_bus: QueryBusProtocol | None = None, 

152 mapper: ObjectMapperProtocol | None = None, 

153 ) -> None: 

154 """Initialize the serializer with optional injected dependencies. 

155 

156 Args: 

157 command_bus: Optional CommandBusProtocol for dispatching commands. 

158 query_bus: Optional QueryBusProtocol for executing queries. 

159 mapper: Optional ObjectMapperProtocol for transformations. 

160 """ 

161 self.command_bus = command_bus 

162 self.query_bus = query_bus 

163 self.mapper = mapper 

164 

165 @classmethod 

166 def _get_registry(cls) -> dict[str, AbstractMediaSerializer]: 

167 if not cls._registry: 

168 # Initialize registry 

169 serializers = [ 

170 JSONSerializer(), 

171 HTMLSerializer(), 

172 PlainTextSerializer(), 

173 XMLSerializer(), 

174 ] 

175 for serializer in serializers: 

176 for mime in serializer.supported_types(): 

177 cls._registry[mime.lower()] = serializer 

178 return cls._registry 

179 

180 @classmethod 

181 def negotiate(cls, accept_header: str | None) -> AbstractMediaSerializer: 

182 """Negotiate the best serializer based on the Accept header.""" 

183 if not accept_header: 

184 return cls._default_serializer 

185 

186 registry = cls._get_registry() 

187 for part in accept_header.split(","): 

188 mime = part.split(";")[0].strip().lower() 

189 if mime in registry: 

190 return registry[mime] 

191 if mime == "*/*": 

192 return cls._default_serializer 

193 

194 return cls._default_serializer 

195 

196 async def serialize( 

197 self, 

198 result: Any, 

199 request: StarletteRequest, 

200 route: Any = None, 

201 ) -> Response: 

202 """Standardize handler result into a Starlette-compatible Response. 

203 

204 Uses injected dependencies instead of service locator pattern. 

205 """ 

206 from lexigram.logging import get_logger 

207 from lexigram.result import Result 

208 from lexigram.web.transport.responses import HTMLContent, JSONResponse 

209 

210 logger = get_logger(__name__) 

211 

212 # 0. Handle None → 204 No Content 

213 # (Status code from decorator is ignored for None, as 204 is semantic for NO content) 

214 if result is None: 

215 return Response(status_code=204) 

216 

217 # Extract status code from route metadata if available 

218 status_code = 200 

219 if route and hasattr(route, "metadata") and isinstance(route.metadata, dict): 

220 status_code = route.metadata.get("status_code", 200) 

221 elif isinstance(route, dict): 

222 status_code = route.get("status_code", 200) 

223 

224 # 1. Handle explicit Response 

225 if isinstance(result, Response): 

226 return result 

227 

228 # 1.5 Handle bytes directly 

229 if isinstance(result, (bytes, bytearray)): 

230 return Response(content=result, media_type="application/octet-stream") 

231 

232 # 1.6 Handle HTMLContent marker (check before bare str since HTMLContent is str subclass) 

233 if isinstance(result, HTMLContent): 

234 from lexigram.web.transport.responses import HTMLResponse 

235 

236 return HTMLResponse(content=str(result)) 

237 

238 # 1.7 Handle bare strings as HTML (HTMX-friendly) 

239 if isinstance(result, str): 

240 return Response(content=result, media_type="text/html") 

241 

242 # 2. Handle Result Object 

243 if isinstance(result, Result): 

244 if result.is_ok(): 

245 result = result.unwrap() 

246 # An Ok that wraps a ready-made Response passes through 

247 # untouched (cookie-setting endpoints need this). 

248 if isinstance(result, Response): 

249 return result 

250 # Continue to normal serialization for the inner value 

251 else: 

252 # Let the DomainExceptionFilter handle the error by raising it 

253 # UNLESS it's an HTTPError we want to return directly. 

254 from lexigram.web.exceptions import HTTPError 

255 

256 error = result.unwrap_err() 

257 if isinstance(error, HTTPError): 

258 return JSONResponse( 

259 content={ 

260 "error": error.code, 

261 "message": error.detail, 

262 "details": error.details, 

263 }, 

264 status_code=error.status_code, 

265 ) 

266 from lexigram.web.routing.result_bridge import ResultResponseMapper 

267 

268 return ResultResponseMapper.error_to_response(error) 

269 

270 # 3. Handle CQRS Messages (Command/Query) 

271 from lexigram.contracts.events.messages import Command, Query 

272 

273 if isinstance(result, (Command, Query)): 

274 try: 

275 if isinstance(result, Command): 

276 if not self.command_bus: 

277 raise ValueError("CommandBusProtocol not injected") 

278 result = await self.command_bus.dispatch(result) 

279 else: 

280 if not self.query_bus: 

281 raise ValueError("QueryBusProtocol not injected") 

282 result = await self.query_bus.execute(result) 

283 

284 # Recursively serialize the bus result 

285 return await self.serialize(result, request, route) 

286 except Exception as e: # noqa: BLE001 — bus dispatch may raise from user-defined command/query handlers; normalise to InternalServerError 

287 from lexigram.web.exceptions import InternalServerError 

288 

289 logger.exception("Bus dispatch failed") 

290 raise InternalServerError(f"Dispatch error: {e}") from e 

291 

292 # 4. Handle ObjectMapper Transformation 

293 if ( 

294 route 

295 and hasattr(route, "metadata") 

296 and "response_model" in route.metadata 

297 and result is not None 

298 and self.mapper 

299 ): 

300 response_model = route.metadata["response_model"] 

301 try: 

302 if self.mapper: 

303 result = self.mapper.map(result, response_model) 

304 except (LookupError, RuntimeError, AttributeError): 

305 logger.debug("Mapping failed for %s", response_model) 

306 

307 # 5.5. Coerce DomainModel → dict for uniform JSON serialization 

308 try: 

309 from lexigram.contracts.domain.base import ( # type: ignore[attr-defined] 

310 DomainModel, 

311 ) 

312 

313 if isinstance(result, DomainModel): 

314 result = ( 

315 result.model_dump() 

316 if hasattr(result, "model_dump") 

317 else vars(result) 

318 ) 

319 except ImportError: 

320 pass 

321 

322 # 6. Delegate to media-specific serializers based on Accept header 

323 accept = request.headers.get("accept") 

324 serializer = self.negotiate(accept) 

325 return await serializer.serialize(result, request, status_code=status_code)