Exemple #1
0
    def test_closes(self):
        with io.BytesIO(b'') as stream:
            # If:
            # ... I create a JSON RPC reader with an opened stream
            reader = JSONRPCReader(stream, logger=utils.get_mock_logger())

            # ... and I close the reader
            reader.close()

            # Then: The stream should be closed
            self.assertTrue(reader.stream.closed)
Exemple #2
0
    def test_closes_exception():
        # Setup: Patch the stream to have a custom close handler
        stream = io.BytesIO(b'')
        close_orig = stream.close
        stream.close = mock.MagicMock(side_effect=AttributeError)

        # If: Close a reader and it throws an exception
        logger = utils.get_mock_logger()
        reader = JSONRPCReader(stream, logger=logger)
        reader.close()

        # Then: There should not have been an exception throws
        logger.exception.assert_called_once()

        # Cleanup: Close the stream
        close_orig()
class JSONRPCServer:
    """
    Handles async requests, async notifications, and async responses
    """
    # CONSTANTS ############################################################
    OUTPUT_THREAD_NAME = u"JSON_RPC_Output_Thread"
    INPUT_THREAD_NAME = u"JSON_RPC_Input_Thread"

    class Handler:
        def __init__(self, class_, handler):
            self.class_ = class_
            self.handler = handler

    def __init__(self, in_stream, out_stream, logger=None, version='0'):
        """
        Initializes internal state of the server and sets up a few useful built-in request handlers
        :param in_stream: Input stream that will provide messages from the client
        :param out_stream: Output stream that will send message to the client
        :param logger: Optional logger
        :param version: Protocol version. Defaults to 0
        """
        self.writer = JSONRPCWriter(out_stream, logger=logger)
        self.reader = JSONRPCReader(in_stream, logger=logger)
        self._logger = logger
        self._version = version
        self._stop_requested = False

        self._output_queue = Queue()

        self._request_handlers = {}
        self._notification_handlers = {}
        self._shutdown_handlers = []

        self._output_consumer = None
        self._input_consumer = None

        # Register built-in handlers
        # 1) Echo
        echo_config = IncomingMessageConfiguration('echo', None)
        self.set_request_handler(echo_config, self._handle_echo_request)

        # 2) Protocol version
        version_config = IncomingMessageConfiguration('version', None)
        self.set_request_handler(version_config, self._handle_version_request)

        # 3) Shutdown/exit
        shutdown_config = IncomingMessageConfiguration('shutdown', None)
        self.set_request_handler(shutdown_config,
                                 self._handle_shutdown_request)
        exit_config = IncomingMessageConfiguration('exit', None)
        self.set_request_handler(exit_config, self._handle_shutdown_request)

    # METHODS ##############################################################

    def add_shutdown_handler(self, handler):
        """
        Adds the provided shutdown handler to the list of shutdown handlers
        :param handler: The callable handler to call when shutdown occurs
        """
        self._shutdown_handlers.append(handler)

    def count_shutdown_handlers(self) -> int:
        """
        Returns the number of shutdown handlers registered
        """
        return len(self._shutdown_handlers)

    def start(self):
        """
        Starts the background threads to listen for responses and requests from the underlying
        streams. Encapsulated into its own method for future async extensions without threads
        """
        if self._logger is not None:
            self._logger.info("JSON RPC server starting...")

        self._output_consumer = threading.Thread(target=self._consume_output,
                                                 name=self.OUTPUT_THREAD_NAME)
        self._output_consumer.daemon = True
        self._output_consumer.start()

        self._input_consumer = threading.Thread(target=self._consume_input,
                                                name=self.INPUT_THREAD_NAME)
        self._input_consumer.daemon = True
        self._input_consumer.start()

    def stop(self):
        """
        Signal input and output threads to halt asap
        """
        self._stop_requested = True

        # Enqueue None to optimistically unblock output thread so it can check for the cancellation flag
        self._output_queue.put(None)

        if self._logger is not None:
            self._logger.info('JSON RPC server stopping...')

    def send_request(self, method, params):
        """
        Add a new request to the output queue
        :param method: Method string of the request
        :param params: Data to send along with the request
        """
        message_id = str(uuid.uuid4())

        # Create the message
        message = JSONRPCMessage.create_request(message_id, method, params)

        # TODO: Add support for handlers for the responses
        # Add the message to the output queue
        self._output_queue.put(message)

    def send_notification(self, method, params):
        """
        Sends a notification, independent of any request
        :param method: String name of the method for the notification
        :param params: Data to send with the notification
        """
        # Create the message
        message = JSONRPCMessage.create_notification(method, params)

        # TODO: Add support for handlers for the responses
        # Add the message to the output queue
        self._output_queue.put(message)

    def set_request_handler(self, config, handler):
        """
        Sets the handler for a request with a given configuration
        :param config: Configuration of the request to listen for
        :param handler: Handler to call when the server receives a request that matches the config
        """
        self._request_handlers[config.method] = self.Handler(
            config.parameter_class, handler)

    def set_notification_handler(self, config, handler):
        """
        Sets the handler for a notification with a given configuration
        :param config: Configuration of the notification to listen for
        :param handler: Handler to call when the server receives a notification that matches the config
        """
        self._notification_handlers[config.method] = self.Handler(
            config.parameter_class, handler)

    def wait_for_exit(self):
        """
        Blocks until both input and output threads return, ie, until the server stops.
        """
        self._input_consumer.join()
        self._output_consumer.join()
        if self._logger is not None:
            self._logger.info('Input and output threads have completed')

        # Close the reader/writer here instead of in the stop method in order to allow "softer"
        # shutdowns that will read or write the last message before halting
        self.reader.close()
        self.writer.close()

    # BUILT-IN HANDLERS ####################################################

    @staticmethod
    def _handle_echo_request(request_context, params):
        request_context.send_response(params)

    def _handle_version_request(self, request_context, params):
        request_context.send_response(self._version)

    def _handle_shutdown_request(self, request_context, params):
        # Signal that the threads should stop
        if self._logger is not None:
            self._logger.info('Received shutdown request')
        self._stop_requested = True

        # Execute the shutdown request handlers
        for handler in self._shutdown_handlers:
            handler()

        self.stop()

    # IMPLEMENTATION DETAILS ###############################################

    def _consume_input(self):
        """
        Listen for messages from the input stream and dispatch them to the registered listeners
        :raises ValueError: The stream was closed. Exit the thread immediately.
        :raises LookupError: No void header with content-length was found
        :raises EOFError: The stream may not contain any bytes yet, so retry.
        """
        if self._logger is not None:
            self._logger.info('Input thread started')

        while not self._stop_requested:
            try:
                message = self.reader.read_message()
                self._dispatch_message(message)

            except EOFError as error:
                # Thread fails once we read EOF. Halt the input thread
                self._log_exception(error, self.INPUT_THREAD_NAME)
                self.stop()
                break
            except (LookupError, ValueError) as error:
                # LookupError: Content-Length header was not found
                # ValueError: JSON deserialization failed
                self._log_exception(error, self.INPUT_THREAD_NAME)
                # Do not halt the input thread
            except Exception as error:
                # Catch generic exceptions
                self._log_exception(error, self.INPUT_THREAD_NAME)
                # Do not halt the input thread

    def _consume_output(self):
        """
        Send output over the output stream
        """
        if self._logger is not None:
            self._logger.info('Output thread started')

        while not self._stop_requested:
            try:
                # Block until queue contains a message to send
                message = self._output_queue.get()
                if message is not None:
                    # It is necessary to check for None here b/c unblock the queue get by adding
                    # None when we want to stop the service
                    self.writer.send_message(message)

            except ValueError as error:
                # Stream is closed, break out of the loop
                self._log_exception(error, self.OUTPUT_THREAD_NAME)
                break
            except Exception as error:
                # Catch generic exceptions without breaking out of loop
                self._log_exception(error, self.OUTPUT_THREAD_NAME)

    def _dispatch_message(self, message):
        """
        Dispatches a message that was received to the necessary handler
        :param message: The message that was received
        """
        if message.message_type in [
                JSONRPCMessageType.ResponseSuccess,
                JSONRPCMessageType.ResponseError
        ]:
            # Responses need to be routed to the handler that requested them
            # TODO: Route to the handler or send error message
            return

        # Figure out which handler will execute the request/notification
        if message.message_type is JSONRPCMessageType.Request:
            if self._logger is not None:
                self._logger.info('Received request id=%s method=%s',
                                  message.message_id, message.message_method)
            handler = self._request_handlers.get(message.message_method)
            request_context = RequestContext(message, self._output_queue)

            # Make sure we got a handler for the request
            if handler is None:
                # TODO: Localize?
                request_context.send_error(
                    f'Requested method is unsupported: {message.message_method}'
                )
                if self._logger is not None:
                    self._logger.warn('Requested method is unsupported: %s',
                                      message.message_method)
                return

            # Call the handler with a request context and the deserialized parameter object
            if handler.class_ is None:
                # Don't attempt to do complex deserialization
                deserialized_object = message.message_params
            else:
                # Use the complex deserializer
                deserialized_object = handler.class_.from_dict(
                    message.message_params)
            try:
                handler.handler(request_context, deserialized_object)
            except Exception as e:
                error_message = f'Unhandled exception while handling request method {message.message_method}: "{e}"'  # TODO: Localize
                if self._logger is not None:
                    self._logger.exception(error_message)
                request_context.send_error(error_message, code=-32603)
        elif message.message_type is JSONRPCMessageType.Notification:
            if self._logger is not None:
                self._logger.info('Received notification method=%s',
                                  message.message_method)
            handler = self._notification_handlers.get(message.message_method)

            if handler is None:
                # Ignore the notification
                if self._logger is not None:
                    self._logger.warn('Notification method %s is unsupported',
                                      message.message_method)
                return

            # Call the handler with a notification context
            notification_context = NotificationContext(self._output_queue)
            deserialized_object = None
            if handler.class_ is None:
                # Don't attempt to do complex deserialization
                deserialized_object = message.message_params
            else:
                # Use the complex deserializer
                deserialized_object = handler.class_.from_dict(
                    message.message_params)
            try:
                handler.handler(notification_context, deserialized_object)
            except Exception:
                error_message = f'Unhandled exception while handling notification method {message.message_method}'
                if self._logger is not None:
                    self._logger.exception(error_message)
        else:
            # If this happens we have a serious issue with the JSON RPC reader
            if self._logger is not None:
                self._logger.warn('Received unsupported message type %s',
                                  message.message_type)
            return

    def _log_exception(self, ex, thread_name):
        """
        Logs an exception if the logger is defined
        :param ex: Exception to log
        :param thread_name: Name of the thread that encountered the exception
        """
        if self._logger is not None:
            self._logger.exception('Thread %s encountered exception %s',
                                   thread_name, ex)