Source code for flask_jsonrpc.site

# Copyright (c) 2020-2025, Cenobit Technologies, Inc. http://cenobit.es/
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
# * Neither the name of the Cenobit Technologies nor the names of
#    its contributors may be used to endorse or promote products derived from
#    this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from __future__ import annotations

from uuid import UUID, uuid4
import typing as t
import logging
from collections import OrderedDict

# Added in version 3.11.
from typing_extensions import Self

from flask import json, request, current_app
from flask.logging import has_level_handler

from typeguard import TypeCheckError
from werkzeug.utils import cached_property
from typeguard._utils import qualified_name
from werkzeug.datastructures import Headers

from flask_jsonrpc.conf import settings
from flask_jsonrpc.helpers import get
from flask_jsonrpc.funcutils import bindfy
from flask_jsonrpc.descriptor import JSONRPCServiceDescriptor
from flask_jsonrpc.exceptions import (
    ParseError,
    ServerError,
    JSONRPCError,
    InvalidParamsError,
    InvalidRequestError,
    MethodNotFoundError,
)
from flask_jsonrpc.types.types import AnnotatedMetadataTypeError, type_checker

JSONRPC_VERSION_DEFAULT: str = '2.0'
JSONRPC_DEFAULT_HTTP_HEADERS: dict[str, str] = {}
JSONRPC_DEFAULT_HTTP_STATUS_CODE: int = 200


[docs] class JSONRPCSite: """JSON-RPC site to handle JSON-RPC requests. Args: version (str): The version of the JSON-RPC API. path (str | None): The URL path for the JSON-RPC site. If None, it will be set later. base_url (str | None): The base URL for the JSON-RPC site. If None, it will be set later. Attributes: path (str | None): The URL path for the JSON-RPC site. base_url (str | None): The base URL for the JSON-RPC site. error_handlers (dict[type[Exception], typing.Callable[[typing.Any], typing.Any]]): A mapping of exception types to their handlers. view_funcs (collections.OrderedDict[str, typing.Callable[..., typing.Any]]): A mapping of method names to their view functions. uuid (uuid.UUID): A unique identifier for the JSON-RPC site. name (str): The name of the JSON-RPC site. version (str): The version of the JSON-RPC API. describe (typing.Callable[[], dict[str, typing.Any]]): A callable that returns the service description. Examples: >>> jsonrpc_site = JSONRPCSite( ... version='2.0', path='/api', base_url='http://localhost/api' ... ) """ def __init__(self: Self, version: str, path: str | None = None, base_url: str | None = None) -> None: self.path = path self.base_url = base_url self.error_handlers: dict[type[Exception], t.Callable[[t.Any], t.Any]] = {} self.view_funcs: t.OrderedDict[str, t.Callable[..., t.Any]] = OrderedDict() self.uuid: UUID = uuid4() self.name: str = 'Flask-JSONRPC' self.version: str = version self.describe = JSONRPCServiceDescriptor(self).describe def _is_notification_request(self: Self, req_json: dict[str, t.Any]) -> bool: """Check if the request is a notification request (without an 'id' member). Args: req_json (dict[str, typing.Any]): The JSON-RPC request data. Returns: bool: True if the request is a notification request, False otherwise. """ return 'id' not in req_json def _is_batch_request(self: Self, req_json: t.Any) -> bool: # noqa: ANN401 """Check if the request is a batch request. Args: req_json (typing.Any): The JSON-RPC request data. Returns: bool: True if the request is a batch request, False otherwise. """ return isinstance(req_json, list) def _find_error_handler(self: Self, exc: Exception) -> t.Callable[[t.Any], t.Any] | None: """Find the appropriate error handler for the given exception. Find the most specific error handler registered for the exception's class or its base classes. Args: exc (Exception): The exception to find a handler for. Returns: typing.Callable[[typing.Any], typing.Any] | None: The error handler if found, None otherwise. """ exc_class = type(exc) if not self.error_handlers: return None for cls in exc_class.__mro__: handler = self.error_handlers.get(cls) if handler is not None: return handler return None @property def is_json(self: Self) -> bool: """Check if the mimetype indicates JSON data, either :mimetype:`application/json` or :mimetype:`application/*+json`. Note: https://github.com/pallets/werkzeug/blob/master/src/werkzeug/wrappers/json.py#L54 Returns: bool: True if the mimetype indicates JSON data, False otherwise. """ mt = request.mimetype return mt in ('application/json', 'application/json-rpc', 'application/jsonrequest') or ( mt.startswith('application/') and mt.endswith('+json') )
[docs] @cached_property def logger(self: Self) -> logging.Logger: """Get the logger for the JSON-RPC site. If the logger does not have any level handlers, a NullHandler is added. Returns: logging.Logger: The logger instance. """ logger = logging.getLogger('flask_jsonrpc') if not has_level_handler(logger): logger.addHandler(logging.NullHandler()) return logger
[docs] def set_path(self: Self, path: str) -> None: """Set the URL path for the JSON-RPC site. Args: path (str): The URL path to set. """ self.path = path
[docs] def set_base_url(self: Self, base_url: str | None) -> None: """Set the base URL for the JSON-RPC site. Args: base_url (str | None): The base URL to set. """ self.base_url = base_url
[docs] def register_error_handler(self: Self, exception: type[Exception], fn: t.Callable[[t.Any], t.Any]) -> None: """Register an error handler for a specific exception type. Args: exception (type[Exception]): The exception type to register the handler for. fn (typing.Callable[[typing.Any], typing.Any]): The error handler function. Examples: >>> class MyException(Exception): ... pass >>> >>> >>> def my_error_handler(exc: MyException) -> dict[str, Any]: ... return {'message': str(exc), 'code': 1234} >>> >>> jsonrpc_site = JSONRPCSite(version='2.0', path='/api') >>> jsonrpc_site.register_error_handler(MyException, my_error_handler) """ self.error_handlers[exception] = fn
[docs] def register(self: Self, name: str, view_func: t.Callable[..., t.Any]) -> None: """Register a view function with the JSON-RPC site. Args: name (str): The name of the method. view_func (typing.Callable[..., typing.Any]): The view function to register. Examples: >>> def my_method(param1: int) -> str: ... return str(param1) >>> jsonrpc_site = JSONRPCSite(version='2.0', path='/api') >>> jsonrpc_site.register('my_method', my_method) """ self.view_funcs[name] = view_func
[docs] def dispatch_request(self: Self) -> tuple[t.Any, int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: """Dispatch the JSON-RPC request. Returns: tuple[typing.Any, int, werkzeug.datastructures.Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: The response data, status code, and headers. Raises: flask_jsonrpc.exceptions.ParseError: If the request is not valid JSON. """ if not self.validate_request(): raise ParseError( data={ 'message': f'Invalid mime type for JSON: {request.mimetype}, ' 'use header Content-Type: application/json' } ) from None json_data = self.to_json(request.data) if self._is_batch_request(json_data): return self.batch_dispatch(json_data) return self.handle_dispatch_except(json_data)
[docs] def validate_request(self: Self) -> bool: """Validate the JSON-RPC request. Returns: bool: True if the request is valid, False otherwise. """ if not self.is_json: self.logger.info('invalid mimetype') return False return True
[docs] def to_json(self: Self, request_data: bytes) -> t.Any: # noqa: ANN401 """Convert the request data to JSON. Args: request_data (bytes): The request data. Returns: typing.Any: The JSON-decoded data. Raises: flask_jsonrpc.exceptions.ParseError: If the request data is not valid JSON. """ try: return json.loads(request_data) except ValueError as e: self.logger.info('invalid json: %s', request_data, exc_info=e) raise ParseError(data={'message': f'Invalid JSON: {request_data!r}'}) from e
[docs] def handle_view_func(self: Self, view_func: t.Callable[..., t.Any], params: t.Any) -> t.Any: # noqa: ANN401 """Handle the view function with the given parameters. Args: view_func (typing.Callable[..., typing.Any]): The view function to handle. params (typing.Any): The parameters to pass to the view function. Returns: typing.Any: The result of the view function. Raises: flask_jsonrpc.exceptions.InvalidParamsError: If the parameters are invalid. flask_jsonrpc.exceptions.InvalidParamsError: If there is an annotated metadata type error. flask_jsonrpc.exceptions.InvalidParamsError: If there is a type checking error. TypeError: If there is a type mismatch. TODO: - Enhance the checker to return the type. """ view_func_params = getattr(view_func, 'jsonrpc_method_params', {}) validate = getattr(view_func, 'jsonrpc_validate', settings.DEFAULT_JSONRPC_METHOD_VALIDATE) try: if isinstance(params, list): kw_params = {} for i, (param_name, _param_type) in enumerate(view_func_params.items()): kw_params[param_name] = (params[i : i + 1] or [None])[0] binded_params = bindfy(view_func, kw_params) elif isinstance(params, dict): binded_params = bindfy(view_func, params) else: raise InvalidParamsError( data={'message': f'Parameter structures are by-position (list) or by-name (dict): {params}'} ) from None if validate: binded_params = type_checker(view_func, binded_params) resp_view = current_app.ensure_sync(view_func)(**binded_params) # TODO: Enhance the checker to return the type view_fun_annotations = t.get_type_hints(view_func) if validate else {} view_fun_return: t.Any | None = view_fun_annotations.pop('return', type(None)) if validate and resp_view is not None and view_fun_return is type(None): resp_view_qn = qualified_name(resp_view) view_fun_return_qn = qualified_name(view_fun_return) raise TypeError( f'return type of {resp_view_qn} must be a type; got {view_fun_return_qn} instead' ) from None return resp_view except AnnotatedMetadataTypeError as e: self.logger.info('invalid annotated type checked for: %s', view_func.__name__, exc_info=e) raise InvalidParamsError( data={ 'constraint': e.annotated.__class__.__name__, 'param': e.name, 'value': e.value, 'message': e.message, } ) from e except (TypeError, TypeCheckError) as e: self.logger.info('invalid type checked for: %s', getattr(view_func, '__name__', view_func), exc_info=e) raise InvalidParamsError(data={'message': str(e)}) from e
[docs] def dispatch( self: Self, req_json: dict[str, t.Any] ) -> tuple[t.Any, int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: """Dispatch the JSON-RPC request. Args: req_json (dict[str, typing.Any]): The JSON-RPC request data. Returns: tuple[typing.Any, int, werkzeug.datastructures.Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: The response data, status code, and headers. Raises: flask_jsonrpc.exceptions.MethodNotFoundError: If the requested method is not found. flask_jsonrpc.exceptions.InvalidRequestError: If the request is invalid. """ method_name = req_json['method'] params = req_json.get('params', {}) view_func = self.view_funcs.get(method_name) notification = getattr(view_func, 'jsonrpc_notification', settings.DEFAULT_JSONRPC_METHOD_NOTIFICATION) if not view_func: raise MethodNotFoundError(data={'message': f'Method not found: {method_name}'}) from None if self._is_notification_request(req_json) and not notification: raise InvalidRequestError( data={ 'message': f"The method {method_name!r} doesn't allow Notification " "Request object (without an 'id' member)" } ) from None resp_view = self.handle_view_func(view_func, params) return self.make_response(req_json, resp_view)
[docs] def handle_exception( self: Self, req_json: dict[str, t.Any], exc: Exception ) -> tuple[t.Any, int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: """Handle exceptions that occur during request dispatch. If no specific error handler is found for the exception, a generic ServerError is used. Args: req_json (dict[str, typing.Any]): The JSON-RPC request data. exc (Exception): The exception that occurred. Returns: tuple[typing.Any, int, werkzeug.datastructures.Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: The response data, status code, and headers. """ self.logger.info('unexpected error', exc_info=exc) jsonrpc_error = ServerError(data={'message': str(exc)}, original_exception=exc) jsonrpc_error_headers: Headers | dict[str, str] | tuple[str] | list[tuple[str]] = JSONRPC_DEFAULT_HTTP_HEADERS error_handler = self._find_error_handler(exc) # If no specific error handler found, use the generic ServerError handler if available if error_handler is None: exc = jsonrpc_error error_handler = self.error_handlers.get(ServerError) if error_handler is not None: resp_view = current_app.ensure_sync(error_handler)(exc) rv, status_code, headers = self.unpack_tuple_returns( resp_view, default_status_code=jsonrpc_error.status_code ) jsonrpc_error.data = rv jsonrpc_error.status_code = status_code jsonrpc_error_headers = headers response = { 'id': get(req_json, 'id'), 'jsonrpc': get(req_json, 'jsonrpc', JSONRPC_VERSION_DEFAULT), 'error': jsonrpc_error.jsonrpc_format, } return response, jsonrpc_error.status_code, jsonrpc_error_headers
[docs] def handle_dispatch_except( self: Self, req_json: dict[str, t.Any] ) -> tuple[t.Any, int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: """Handle the dispatch of the request and catch exceptions. If an exception occurs during dispatch, it is handled appropriately. Args: req_json (dict[str, typing.Any]): The JSON-RPC request data. Returns: tuple[typing.Any, int, werkzeug.datastructures.Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: The response data, status code, and headers. """ try: if not self.validate(req_json): raise InvalidRequestError(data={'message': f'Invalid JSON: {req_json!r}'}) from None return self.dispatch(req_json) except Exception as e: if isinstance(e, JSONRPCError): # mypyc: https://docs.python.org/3/glossary.html#term-EAFP self.logger.info('jsonrpc error', exc_info=e) response = { 'id': get(req_json, 'id'), 'jsonrpc': get(req_json, 'jsonrpc', JSONRPC_VERSION_DEFAULT), 'error': e.jsonrpc_format, } return response, e.status_code, JSONRPC_DEFAULT_HTTP_HEADERS return self.handle_exception(req_json, e)
[docs] def batch_dispatch( self: Self, reqs_json: list[dict[str, t.Any]] ) -> tuple[list[t.Any], int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: """Dispatch a batch of JSON-RPC requests. Args: reqs_json (list[dict[str, typing.Any]]): The list of JSON-RPC request data. Returns: tuple[ list[typing.Any], int, werkzeug.datastructures.Headers | dict[str, str] | tuple[str] | list[tuple[str]] ]: The list of response data, status code, and headers. Raises: flask_jsonrpc.exceptions.InvalidRequestError: If the batch request is empty. """ if not reqs_json: raise InvalidRequestError(data={'message': 'Empty array'}) from None resp_views = [] headers = Headers() status_code = JSONRPC_DEFAULT_HTTP_STATUS_CODE for rv, _, hdrs in [self.handle_dispatch_except(rq) for rq in reqs_json]: headers.update([hdrs] if isinstance(hdrs, tuple) else hdrs) # type: ignore if rv is None: continue resp_views.append(rv) if not resp_views: status_code = 204 return resp_views, status_code, headers
[docs] def validate(self: Self, req_json: dict[str, t.Any]) -> bool: """Validate the JSON-RPC request structure. Args: req_json (dict[str, typing.Any]): The JSON-RPC request data. Returns: bool: True if the request is valid, False otherwise. """ return isinstance(req_json, dict) and 'method' in req_json
[docs] def unpack_tuple_returns( self: Self, resp_view: t.Any, # noqa: ANN401 default_status_code: int = JSONRPC_DEFAULT_HTTP_STATUS_CODE, default_headers: Headers | dict[str, str] | tuple[str] | list[tuple[str]] = JSONRPC_DEFAULT_HTTP_HEADERS, ) -> tuple[t.Any, int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: """Unpack the response tuple returned by a view function. Note: https://github.com/pallets/flask/blob/d091bb00c0358e9f30006a064f3dbb671b99aeae/src/flask/app.py#L1981 Args: resp_view (typing.Any): The response returned by the view function. default_status_code (int): The default HTTP status code to use if not specified. default_headers (werkzeug.datastructures.Headers | dict[str, str] | tuple[str] | list[tuple[str]]): The default HTTP headers to use if not specified. Returns: tuple[typing.Any, int, werkzeug.datastructures.Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: The unpacked response body, status code, and headers. """ # https://github.com/pallets/flask/blob/d091bb00c0358e9f30006a064f3dbb671b99aeae/src/flask/app.py#L1981 if isinstance(resp_view, tuple): len_resp_view = len(resp_view) # a 3-tuple is unpacked directly if len_resp_view == 3: rv, status_code, headers = resp_view # decide if a 2-tuple has status or headers elif len_resp_view == 2: if isinstance(resp_view[1], Headers | dict | tuple | list): rv, headers, status_code = resp_view + (default_status_code,) else: rv, status_code, headers = resp_view + (default_headers,) # other sized tuples are not allowed else: raise TypeError( 'the view function did not return a valid response tuple.' ' The tuple must have the form (body, status, headers),' ' (body, status), or (body, headers).' ) from None return rv, status_code, headers return resp_view, default_status_code, default_headers
[docs] def make_response( self: Self, req_json: dict[str, t.Any], resp_view: t.Any, # noqa: ANN401 ) -> tuple[t.Any, int, Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: """Make a JSON-RPC response. Args: req_json (dict[str, typing.Any]): The JSON-RPC request data. resp_view (typing.Any): The response returned by the view function. Returns: tuple[typing.Any, int, werkzeug.datastructures.Headers | dict[str, str] | tuple[str] | list[tuple[str]]]: The response data, status code, and headers. """ rv, status_code, headers = self.unpack_tuple_returns(resp_view) if self._is_notification_request(req_json): return None, 204, headers resp = {'id': req_json.get('id'), 'jsonrpc': req_json.get('jsonrpc', JSONRPC_VERSION_DEFAULT), 'result': rv} return resp, status_code, headers