Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add error handler to unexpected exception #568

Merged
merged 1 commit into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions examples/minimal/minimal.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@
jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True)


class MyException(Exception):
pass


@jsonrpc.errorhandler(MyException)
def handle_my_exception(ex: MyException) -> t.Dict[str, t.Any]:
return {'message': 'It is a custom exception', 'code': '0001'}


@jsonrpc.method('App.index')
def index() -> str:
return 'Welcome to Flask JSON-RPC'
Expand Down Expand Up @@ -82,6 +91,11 @@ def fails(_string: t.Optional[str] = None) -> t.NoReturn:
raise ValueError('example of fail')


@jsonrpc.method('App.failsWithCustomException')
def fails_with_custom_exception(_string: t.Optional[str] = None) -> t.NoReturn:
raise MyException('example of fail with custom exception that will be handled')


@jsonrpc.method('App.sum')
def sum_(a: Real, b: Real) -> Real:
return a + b
Expand Down
32 changes: 32 additions & 0 deletions examples/modular/api/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,43 @@
# 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.
import typing as t
from dataclasses import dataclass

from flask_jsonrpc import JSONRPCBlueprint

article = JSONRPCBlueprint('article', __name__)


class ArticleException(Exception):
def __init__(self: t.Self, *args: object) -> None:
super().__init__(*args)


class ArticleNotFoundException(ArticleException):
def __init__(self: t.Self, message: str, article_id: int) -> None:
super().__init__(message)
self.article_id = article_id


@dataclass
class Article:
id: int
name: str


@article.errorhandler(ArticleNotFoundException)
def handle_user_not_found_exception(ex: ArticleNotFoundException) -> t.Dict[str, t.Any]:
return {'message': f'Article {ex.article_id} not found', 'code': '2001'}


@article.method('Article.index')
def index() -> str:
return 'Welcome to Article API'


@article.method('Article.getArticle')
def get_article(id: int) -> Article:
if id > 10:
raise ArticleNotFoundException('Article not found', article_id=id)
return Article(id=id, name='Founded')
40 changes: 40 additions & 0 deletions examples/modular/api/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,51 @@
# 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.
import typing as t

from flask_jsonrpc import JSONRPCBlueprint

user = JSONRPCBlueprint('user', __name__)


class UserException(Exception):
def __init__(self: t.Self, *args: object) -> None:
super().__init__(*args)


class UserNotFoundException(UserException):
def __init__(self: t.Self, message: str, user_id: int) -> None:
super().__init__(message)
self.user_id = user_id


class User:
def __init__(self: t.Self, id: int, name: str) -> None:
self.id = id
self.name = name


def handle_user_not_found_exception(ex: UserNotFoundException) -> t.Dict[str, t.Any]:
return {'message': f'User {ex.user_id} not found', 'code': '1001'}


user.register_error_handler(UserNotFoundException, handle_user_not_found_exception)


@user.method('User.index')
def index() -> str:
return 'Welcome to User API'


@user.method('User.getUser')
def get_user(id: int) -> User:
if id > 10:
raise UserNotFoundException('User not found', user_id=id)
return User(id, 'Founded')


@user.method('User.removeUser')
def remove_user(id: int) -> User:
if id > 10:
raise ValueError('User not found')
return User(id, 'Removed')
7 changes: 7 additions & 0 deletions examples/modular/modular.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# isort:skip_file
import typing as t
import os
import sys

Expand All @@ -49,6 +50,12 @@
jsonrpc.register_blueprint(app, user, url_prefix='/user', enable_web_browsable_api=True)
jsonrpc.register_blueprint(app, article, url_prefix='/article', enable_web_browsable_api=True)

jsonrpc.errorhandler(ValueError)


def handle_value_error_exception(ex: ValueError) -> t.Dict[str, t.Any]:
return {'message': 'Generic global error handler does not work, :(', 'code': '0000'}


@jsonrpc.method('App.index')
def index() -> str:
Expand Down
6 changes: 5 additions & 1 deletion src/flask_jsonrpc/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from .globals import default_jsonrpc_site, default_jsonrpc_site_api
from .helpers import urn
from .handlers import JSONRPCErrorHandlerDecoratorMixin
from .wrappers import JSONRPCDecoratorMixin
from .contrib.browse import JSONRPCBrowse

Expand All @@ -46,7 +47,7 @@
from .blueprints import JSONRPCBlueprint


class JSONRPC(JSONRPCDecoratorMixin):
class JSONRPC(JSONRPCDecoratorMixin, JSONRPCErrorHandlerDecoratorMixin):
def __init__(
self: Self,
app: t.Optional[Flask] = None,
Expand Down Expand Up @@ -135,6 +136,9 @@ def register_blueprint(
if app.config['DEBUG'] or enable_web_browsable_api:
self.register_browse(jsonrpc_app)

def register_error_handler(self: Self, exception: t.Type[Exception], fn: t.Callable[[t.Any], t.Any]) -> None:
super().register_error_handler(exception, fn)

def init_browse_app(self: Self, app: Flask, path: t.Optional[str] = None, base_url: t.Optional[str] = None) -> None:
browse_url = self._make_jsonrpc_browse_url(path or self.path)
self.jsonrpc_browse = JSONRPCBrowse(app, url_prefix=browse_url, base_url=base_url or self.base_url)
Expand Down
8 changes: 7 additions & 1 deletion src/flask_jsonrpc/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
# POSSIBILITY OF SUCH DAMAGE.
import typing as t

from flask import typing as ft

from .globals import default_jsonrpc_site, default_jsonrpc_site_api
from .handlers import JSONRPCErrorHandlerDecoratorMixin
from .wrappers import JSONRPCDecoratorMixin

# Python 3.10+
Expand All @@ -40,7 +43,7 @@
from .views import JSONRPCView


class JSONRPCBlueprint(JSONRPCDecoratorMixin):
class JSONRPCBlueprint(JSONRPCDecoratorMixin, JSONRPCErrorHandlerDecoratorMixin):
def __init__(
self: Self,
name: str,
Expand All @@ -58,3 +61,6 @@ def get_jsonrpc_site(self: Self) -> 'JSONRPCSite':

def get_jsonrpc_site_api(self: Self) -> t.Type['JSONRPCView']:
return self.jsonrpc_site_api

def register_error_handler(self: Self, exception: t.Type[Exception], fn: ft.ErrorHandlerCallable) -> None:
super().register_error_handler(exception, fn)
1 change: 0 additions & 1 deletion src/flask_jsonrpc/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ def __init__(
@property
def jsonrpc_format(self: Self) -> t.Dict[str, t.Any]:
"""Return the Exception data in a format for JSON-RPC"""

error = {'name': self.__class__.__name__, 'code': self.code, 'message': self.message, 'data': self.data}

# RuntimeError: Working outside of application context.
Expand Down
53 changes: 53 additions & 0 deletions src/flask_jsonrpc/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright (c) 2020-2024, 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.
import typing as t

# Python 3.10+
try:
from typing import Self
except ImportError: # pragma: no cover
from typing_extensions import Self

if t.TYPE_CHECKING:
from .site import JSONRPCSite


class JSONRPCErrorHandlerDecoratorMixin:
def get_jsonrpc_site(self: Self) -> 'JSONRPCSite':
raise NotImplementedError('.get_jsonrpc_site must be overridden') from None

def register_error_handler(self: Self, exception: t.Type[Exception], fn: t.Callable[[t.Any], t.Any]) -> None:
self.get_jsonrpc_site().register_error_handler(exception, fn)

def errorhandler(
self: Self, exception: t.Type[Exception]
) -> t.Callable[[t.Callable[[t.Any], t.Any]], t.Callable[[t.Any], t.Any]]:
def decorator(fn: t.Callable[[t.Any], t.Any]) -> t.Callable[[t.Any], t.Any]:
self.register_error_handler(exception, fn)
return fn

return decorator
21 changes: 20 additions & 1 deletion src/flask_jsonrpc/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class JSONRPCSite:
def __init__(self: Self, path: t.Optional[str] = None, base_url: t.Optional[str] = None) -> None:
self.path = path
self.base_url = base_url
self.error_handlers: t.Dict[t.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'
Expand All @@ -86,6 +87,9 @@ def set_path(self: Self, path: str) -> None:
def set_base_url(self: Self, base_url: t.Optional[str]) -> None:
self.base_url = base_url

def register_error_handler(self: Self, exception: t.Type[Exception], fn: t.Callable[[t.Any], t.Any]) -> None:
self.error_handlers[exception] = fn

def register(self: Self, name: str, view_func: t.Callable[..., t.Any]) -> None:
self.view_funcs[name] = view_func

Expand Down Expand Up @@ -173,6 +177,17 @@ def dispatch(
resp_view = self.handle_view_func(view_func, params)
return self.make_response(req_json, resp_view)

def _find_error_handler(self: Self, exc: Exception) -> t.Optional[t.Callable[[t.Any], t.Any]]:
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

def handle_dispatch_except(
self: Self, req_json: t.Dict[str, t.Any]
) -> t.Tuple[t.Any, int, t.Union[Headers, t.Dict[str, str], t.Tuple[str], t.List[t.Tuple[str]]]]:
Expand All @@ -190,7 +205,11 @@ def handle_dispatch_except(
return response, e.status_code, JSONRPC_DEFAULT_HTTP_HEADERS
except Exception as e: # pylint: disable=W0703
current_app.logger.exception('unexpected error')
jsonrpc_error = ServerError(data={'message': str(e)})
error_handler = self._find_error_handler(e)
jsonrpc_error_data = (
current_app.ensure_sync(error_handler)(e) if error_handler is not None else {'message': str(e)}
)
jsonrpc_error = ServerError(data=jsonrpc_error_data)
response = {
'id': get(req_json, 'id'),
'jsonrpc': get(req_json, 'jsonrpc', JSONRPC_VERSION_DEFAULT),
Expand Down
Loading