import asyncio import json import logging import sys import aiohttp from aiohttp import ClientError from aiohttp.http_exceptions import HttpProcessingError import jsonrpc_base from jsonrpc_base import TransportError _LOGGER = logging.getLogger(__name__) if sys.version_info >= (3, 11): from asyncio import timeout as async_timeout else: from async_timeout import timeout as async_timeout class Server(jsonrpc_base.Server): """A connection to a HTTP JSON-RPC server, backed by aiohttp""" def __init__(self, url, session=None, **connect_kwargs): super().__init__() self._session = session or aiohttp.ClientSession() # True if we made our own session self._internal_session = session is None self._client = None self._connect_kwargs = connect_kwargs self._url = url self._connect_kwargs['headers'] = self._connect_kwargs.get( 'headers', {}) self._connect_kwargs['headers']['Content-Type'] = ( self._connect_kwargs['headers'].get( 'Content-Type', 'application/json')) self._connect_kwargs['headers']['Accept'] = ( self._connect_kwargs['headers'].get( 'Accept', 'application/json-rpc')) self._timeout = self._connect_kwargs.get('timeout') self._pending_messages = {} async def send_message(self, message): """Send the HTTP message to the server and return the message response. No result is returned if message is a notification. """ if self._client is None: raise TransportError('Client is not connected.', message) try: await self._client.send_str(message.serialize()) if message.response_id: pending_message = PendingMessage() self._pending_messages[message.response_id] = pending_message response = await pending_message.wait(self._timeout) del self._pending_messages[message.response_id] else: response = None return message.parse_response(response) except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: raise TransportError('Transport Error', message, exc) async def ws_connect(self): """Connect to the websocket server.""" if self.connected: raise TransportError('Connection already open.') try: if self._internal_session and self._session.closed: self._session = aiohttp.ClientSession() self._client = await self._session.ws_connect( self._url, **self._connect_kwargs) except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: raise TransportError('Error connecting to server', None, exc) return asyncio.get_running_loop().create_task(self._ws_loop()) async def _ws_loop(self): """Listen for messages from the websocket server.""" msg = None try: async for msg in self._client: if msg.type == aiohttp.WSMsgType.ERROR: break elif msg.type == aiohttp.WSMsgType.BINARY: try: # If we get a binary message, try and decode it as a # UTF-8 JSON string, in case the server is sending # binary websocket messages. If it doens't decode we'll # ignore it since we weren't expecting binary messages # anyway data = json.loads(msg.data.decode()) except ValueError: continue elif msg.type == aiohttp.WSMsgType.TEXT: try: data = msg.json() except ValueError as exc: raise TransportError('Error Parsing JSON', None, exc) else: # This is tested with test_message_ping_ignored, but # cpython's optimizations prevent coveragepy from detecting # that it's run # https://bitbucket.org/ned/coveragepy/issues/198/continue-marked-as-not-covered continue # pragma: no cover if 'method' in data: request = jsonrpc_base.Request.parse(data) # Handle method call in a task to prevent blocking the read # loop asyncio.get_running_loop().create_task( self._receive_request(request)) else: self._pending_messages[data['id']].response = data except (ClientError, HttpProcessingError, asyncio.TimeoutError) as exc: raise TransportError('Transport Error', None, exc) finally: await self.close() if msg and msg.type == aiohttp.WSMsgType.ERROR: raise TransportError( 'Websocket error detected. Connection closed.') async def close(self): """Close the connection to the websocket server.""" if self.connected: await self._client.close() self._client = None if self._internal_session: await self._session.close() @property def connected(self): """Websocket server is connected.""" return self._client is not None async def _receive_request(self, request: jsonrpc_base.Request): """Handle request and log errors""" try: response = await self.async_receive_request(request) if response: await self.send_message(response) except Exception as exc: _LOGGER.exception('Error while handling request', exc_info=exc) class PendingMessage(object): """Wait for response of pending message.""" def __init__(self): self._event = asyncio.Event() self._response = None async def wait(self, timeout=None): async with async_timeout(timeout): await self._event.wait() return self._response @property def response(self): return self._response @response.setter def response(self, value): self._response = value self._event.set()