_transport.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. # mypy: disable-error-code="no-any-return"
  2. """Shared plumbing for the ConnectRPC-based transport.
  3. Chatto's public API is a ConnectRPC service surface. ``chattolib.client``
  4. speaks to it through generated service stubs (see ``chattolib._pb``) driven
  5. by the official ``connectrpc`` Python package.
  6. This module exposes:
  7. * :func:`build_service_clients` — one call, one ``base_url`` argument,
  8. returns a ``ServiceClients`` object with a typed field per Chatto service.
  9. * :func:`translate_connect_error` — translates a
  10. ``connectrpc.errors.ConnectError`` into the library's public exception
  11. hierarchy (:class:`chattolib.exceptions.ChattoAuthError` /
  12. :class:`chattolib.exceptions.ChattoConnectError`).
  13. * :func:`pb_to_dict` — turns a protobuf response into the camelCase JSON
  14. shape that the existing ``types.py`` dataclass parsers already accept.
  15. Keeping the parsers dict-driven means the migration from Connect-JSON to
  16. Connect-binary transport doesn't ripple through the entire public API.
  17. """
  18. from __future__ import annotations
  19. from dataclasses import dataclass
  20. from typing import Any
  21. from connectrpc.code import Code
  22. from connectrpc.compat import google_protobuf_binary_codec
  23. from connectrpc.errors import ConnectError
  24. from google.protobuf.json_format import MessageToDict
  25. from google.protobuf.message import Message
  26. # Sync client twins (generated alongside the async ones in every *_connect.py).
  27. from chattolib._pb.chatto.admin.v1.diagnostics_connect import (
  28. AdminDiagnosticsServiceClient,
  29. AdminDiagnosticsServiceClientSync,
  30. )
  31. from chattolib._pb.chatto.admin.v1.event_log_connect import (
  32. AdminEventLogServiceClient,
  33. AdminEventLogServiceClientSync,
  34. )
  35. from chattolib._pb.chatto.admin.v1.members_connect import (
  36. AdminUserServiceClient,
  37. AdminUserServiceClientSync,
  38. )
  39. from chattolib._pb.chatto.admin.v1.permissions_connect import (
  40. AdminPermissionServiceClient,
  41. AdminPermissionServiceClientSync,
  42. )
  43. from chattolib._pb.chatto.admin.v1.roles_connect import (
  44. AdminRoleServiceClient,
  45. AdminRoleServiceClientSync,
  46. )
  47. from chattolib._pb.chatto.admin.v1.room_layout_connect import (
  48. AdminRoomLayoutServiceClient,
  49. AdminRoomLayoutServiceClientSync,
  50. )
  51. from chattolib._pb.chatto.admin.v1.server_connect import (
  52. AdminServerServiceClient,
  53. AdminServerServiceClientSync,
  54. )
  55. from chattolib._pb.chatto.api.v1.account_connect import (
  56. MyAccountServiceClient,
  57. MyAccountServiceClientSync,
  58. )
  59. from chattolib._pb.chatto.api.v1.asset_uploads_connect import (
  60. AssetUploadServiceClient,
  61. AssetUploadServiceClientSync,
  62. )
  63. from chattolib._pb.chatto.api.v1.attachments_connect import (
  64. AssetServiceClient,
  65. AssetServiceClientSync,
  66. )
  67. from chattolib._pb.chatto.api.v1.member_directory_connect import (
  68. UserServiceClient,
  69. UserServiceClientSync,
  70. )
  71. from chattolib._pb.chatto.api.v1.messages_connect import (
  72. MessageServiceClient,
  73. MessageServiceClientSync,
  74. )
  75. from chattolib._pb.chatto.api.v1.notification_preferences_connect import (
  76. NotificationPreferencesServiceClient,
  77. NotificationPreferencesServiceClientSync,
  78. )
  79. from chattolib._pb.chatto.api.v1.notifications_connect import (
  80. NotificationServiceClient,
  81. NotificationServiceClientSync,
  82. )
  83. from chattolib._pb.chatto.api.v1.push_notifications_connect import (
  84. PushNotificationServiceClient,
  85. PushNotificationServiceClientSync,
  86. )
  87. from chattolib._pb.chatto.api.v1.roles_connect import RoleServiceClient, RoleServiceClientSync
  88. from chattolib._pb.chatto.api.v1.room_directory_connect import (
  89. RoomDirectoryServiceClient,
  90. RoomDirectoryServiceClientSync,
  91. )
  92. from chattolib._pb.chatto.api.v1.rooms_connect import RoomServiceClient, RoomServiceClientSync
  93. from chattolib._pb.chatto.api.v1.server_state_connect import (
  94. ServerServiceClient,
  95. ServerServiceClientSync,
  96. )
  97. from chattolib._pb.chatto.api.v1.threads_connect import ThreadServiceClient, ThreadServiceClientSync
  98. from chattolib._pb.chatto.api.v1.viewer_connect import ViewerServiceClient, ViewerServiceClientSync
  99. from chattolib._pb.chatto.api.v1.voice_calls_connect import (
  100. VoiceCallServiceClient,
  101. VoiceCallServiceClientSync,
  102. )
  103. from chattolib._pb.chatto.auth.v1.external_identity_auth_connect import (
  104. ExternalIdentityAuthServiceClient,
  105. ExternalIdentityAuthServiceClientSync,
  106. )
  107. from chattolib._pb.chatto.discovery.v1.server_connect import (
  108. ServerDiscoveryServiceClient,
  109. ServerDiscoveryServiceClientSync,
  110. )
  111. from chattolib.exceptions import ChattoAuthError, ChattoConnectError
  112. CONNECT_PREFIX = "/api/connect"
  113. @dataclass
  114. class ServiceClients:
  115. """Typed bundle of ConnectRPC service clients used by ``ChattoClient``."""
  116. server_discovery: ServerDiscoveryServiceClient
  117. server: ServerServiceClient
  118. viewer: ViewerServiceClient
  119. account: MyAccountServiceClient
  120. users: UserServiceClient
  121. roles: RoleServiceClient
  122. room_directory: RoomDirectoryServiceClient
  123. rooms: RoomServiceClient
  124. messages: MessageServiceClient
  125. threads: ThreadServiceClient
  126. notifications: NotificationServiceClient
  127. notification_prefs: NotificationPreferencesServiceClient
  128. push: PushNotificationServiceClient
  129. assets: AssetServiceClient
  130. asset_uploads: AssetUploadServiceClient
  131. voice_calls: VoiceCallServiceClient
  132. external_auth: ExternalIdentityAuthServiceClient
  133. admin_server: AdminServerServiceClient
  134. admin_room_layout: AdminRoomLayoutServiceClient
  135. admin_users: AdminUserServiceClient
  136. admin_roles: AdminRoleServiceClient
  137. admin_event_log: AdminEventLogServiceClient
  138. admin_diagnostics: AdminDiagnosticsServiceClient
  139. admin_permissions: AdminPermissionServiceClient
  140. async def close(self) -> None:
  141. for name in self.__dataclass_fields__:
  142. client = getattr(self, name)
  143. await client.close()
  144. def build_service_clients(base_url: str) -> ServiceClients:
  145. """Instantiate one service client per Chatto Connect service.
  146. ``base_url`` is the server root (e.g. ``https://chat.chatto.run``); the
  147. ConnectRPC prefix is appended by this function.
  148. """
  149. address = f"{base_url.rstrip('/')}{CONNECT_PREFIX}"
  150. codec = google_protobuf_binary_codec()
  151. def make(cls: Any) -> Any:
  152. return cls(address, codec=codec)
  153. return ServiceClients(
  154. server_discovery=make(ServerDiscoveryServiceClient),
  155. server=make(ServerServiceClient),
  156. viewer=make(ViewerServiceClient),
  157. account=make(MyAccountServiceClient),
  158. users=make(UserServiceClient),
  159. roles=make(RoleServiceClient),
  160. room_directory=make(RoomDirectoryServiceClient),
  161. rooms=make(RoomServiceClient),
  162. messages=make(MessageServiceClient),
  163. threads=make(ThreadServiceClient),
  164. notifications=make(NotificationServiceClient),
  165. notification_prefs=make(NotificationPreferencesServiceClient),
  166. push=make(PushNotificationServiceClient),
  167. assets=make(AssetServiceClient),
  168. asset_uploads=make(AssetUploadServiceClient),
  169. voice_calls=make(VoiceCallServiceClient),
  170. external_auth=make(ExternalIdentityAuthServiceClient),
  171. admin_server=make(AdminServerServiceClient),
  172. admin_room_layout=make(AdminRoomLayoutServiceClient),
  173. admin_users=make(AdminUserServiceClient),
  174. admin_roles=make(AdminRoleServiceClient),
  175. admin_event_log=make(AdminEventLogServiceClient),
  176. admin_diagnostics=make(AdminDiagnosticsServiceClient),
  177. admin_permissions=make(AdminPermissionServiceClient),
  178. )
  179. def translate_connect_error(exc: ConnectError) -> Exception:
  180. """Convert a ``connectrpc`` error into chattolib's exception hierarchy."""
  181. if exc.code == Code.UNAUTHENTICATED:
  182. return ChattoAuthError(str(exc))
  183. return ChattoConnectError(
  184. code=exc.code.name.lower(),
  185. message=str(exc),
  186. )
  187. # --- Synchronous transport -------------------------------------------------
  188. #
  189. # The async client translates ConnectError via ``ChattoClient._rpc``, which
  190. # awaits the coroutine inside a try/except. The sync stubs raise at call time
  191. # (before the generated ``_rpc`` passthrough runs), so error translation is
  192. # done here at the transport via a ConnectRPC unary interceptor instead.
  193. class _ErrorTranslatingInterceptorSync:
  194. """Structural ``connectrpc.UnaryInterceptorSync`` that maps ConnectError."""
  195. def intercept_unary_sync(self, call_next: Any, request: Any, ctx: Any) -> Any:
  196. try:
  197. return call_next(request, ctx)
  198. except ConnectError as exc:
  199. raise translate_connect_error(exc) from exc
  200. @dataclass
  201. class ServiceClientsSync:
  202. """Synchronous twin of :class:`ServiceClients`."""
  203. server_discovery: ServerDiscoveryServiceClientSync
  204. server: ServerServiceClientSync
  205. viewer: ViewerServiceClientSync
  206. account: MyAccountServiceClientSync
  207. users: UserServiceClientSync
  208. roles: RoleServiceClientSync
  209. room_directory: RoomDirectoryServiceClientSync
  210. rooms: RoomServiceClientSync
  211. messages: MessageServiceClientSync
  212. threads: ThreadServiceClientSync
  213. notifications: NotificationServiceClientSync
  214. notification_prefs: NotificationPreferencesServiceClientSync
  215. push: PushNotificationServiceClientSync
  216. assets: AssetServiceClientSync
  217. asset_uploads: AssetUploadServiceClientSync
  218. voice_calls: VoiceCallServiceClientSync
  219. external_auth: ExternalIdentityAuthServiceClientSync
  220. admin_server: AdminServerServiceClientSync
  221. admin_room_layout: AdminRoomLayoutServiceClientSync
  222. admin_users: AdminUserServiceClientSync
  223. admin_roles: AdminRoleServiceClientSync
  224. admin_event_log: AdminEventLogServiceClientSync
  225. admin_diagnostics: AdminDiagnosticsServiceClientSync
  226. admin_permissions: AdminPermissionServiceClientSync
  227. def close(self) -> None:
  228. for name in self.__dataclass_fields__:
  229. getattr(self, name).close()
  230. def build_service_clients_sync(base_url: str) -> ServiceClientsSync:
  231. """Synchronous twin of :func:`build_service_clients`.
  232. Each client carries an error-translating interceptor so ConnectError
  233. surfaces as ``ChattoConnectError`` / ``ChattoAuthError`` exactly as the
  234. async path does.
  235. """
  236. address = f"{base_url.rstrip('/')}{CONNECT_PREFIX}"
  237. codec = google_protobuf_binary_codec()
  238. interceptor = _ErrorTranslatingInterceptorSync()
  239. def make(cls: Any) -> Any:
  240. return cls(address, codec=codec, interceptors=(interceptor,))
  241. return ServiceClientsSync(
  242. server_discovery=make(ServerDiscoveryServiceClientSync),
  243. server=make(ServerServiceClientSync),
  244. viewer=make(ViewerServiceClientSync),
  245. account=make(MyAccountServiceClientSync),
  246. users=make(UserServiceClientSync),
  247. roles=make(RoleServiceClientSync),
  248. room_directory=make(RoomDirectoryServiceClientSync),
  249. rooms=make(RoomServiceClientSync),
  250. messages=make(MessageServiceClientSync),
  251. threads=make(ThreadServiceClientSync),
  252. notifications=make(NotificationServiceClientSync),
  253. notification_prefs=make(NotificationPreferencesServiceClientSync),
  254. push=make(PushNotificationServiceClientSync),
  255. assets=make(AssetServiceClientSync),
  256. asset_uploads=make(AssetUploadServiceClientSync),
  257. voice_calls=make(VoiceCallServiceClientSync),
  258. external_auth=make(ExternalIdentityAuthServiceClientSync),
  259. admin_server=make(AdminServerServiceClientSync),
  260. admin_room_layout=make(AdminRoomLayoutServiceClientSync),
  261. admin_users=make(AdminUserServiceClientSync),
  262. admin_roles=make(AdminRoleServiceClientSync),
  263. admin_event_log=make(AdminEventLogServiceClientSync),
  264. admin_diagnostics=make(AdminDiagnosticsServiceClientSync),
  265. admin_permissions=make(AdminPermissionServiceClientSync),
  266. )
  267. def pb_to_dict(message: Message | None) -> dict[str, Any]:
  268. """Convert a protobuf message to the camelCase dict shape the parsers accept.
  269. ``preserving_proto_field_name=False`` gives us JSON-mapping camelCase
  270. keys (e.g. ``created_at`` → ``createdAt``), matching what the
  271. ``types.py`` dataclass parsers already consume.
  272. """
  273. if message is None:
  274. return {}
  275. return MessageToDict(
  276. message,
  277. preserving_proto_field_name=False,
  278. use_integers_for_enums=False,
  279. )