From ce46b16ee628ce316de2f02e99538a091d1b2c1e Mon Sep 17 00:00:00 2001 From: Nycholas de Oliveira e Oliveira Date: Fri, 27 Sep 2024 12:11:42 -0300 Subject: [PATCH] Add error handler to unexpected exception Example: ``` app = Flask('minimal') 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.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') ```` Note: The implementation does not support a global error handler, only a local one. In other words, the registration of an error handler will only be available for the JSON-RPC that has been registered. Resolve #375 --- examples/minimal/minimal.py | 14 +++ examples/modular/api/article.py | 32 ++++++ examples/modular/api/user.py | 40 +++++++ examples/modular/modular.py | 7 ++ src/flask_jsonrpc/app.py | 6 +- src/flask_jsonrpc/blueprints.py | 8 +- src/flask_jsonrpc/exceptions.py | 1 - src/flask_jsonrpc/handlers.py | 53 ++++++++++ src/flask_jsonrpc/site.py | 21 +++- tests/integration/test_app.py | 119 +++++++++++++-------- tests/test_apps/app/__init__.py | 103 +++++++++++++++--- tests/test_apps/async_app/__init__.py | 116 +++++++++++++++++--- tests/unit/test_app.py | 124 ++++++++++++++++++++++ tests/unit/test_async_app.py | 139 +++++++++++++++++++++++- tests/unit/test_async_client.py | 147 ++++++++++++++++++-------- tests/unit/test_client.py | 143 ++++++++++++++++++------- 16 files changed, 914 insertions(+), 159 deletions(-) create mode 100644 src/flask_jsonrpc/handlers.py diff --git a/examples/minimal/minimal.py b/examples/minimal/minimal.py index fd32787b..efc3ce89 100755 --- a/examples/minimal/minimal.py +++ b/examples/minimal/minimal.py @@ -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' @@ -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 diff --git a/examples/modular/api/article.py b/examples/modular/api/article.py index cd16e579..4d6a3372 100644 --- a/examples/modular/api/article.py +++ b/examples/modular/api/article.py @@ -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') diff --git a/examples/modular/api/user.py b/examples/modular/api/user.py index 448008e2..acfec517 100644 --- a/examples/modular/api/user.py +++ b/examples/modular/api/user.py @@ -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') diff --git a/examples/modular/modular.py b/examples/modular/modular.py index bcddde09..8470d057 100755 --- a/examples/modular/modular.py +++ b/examples/modular/modular.py @@ -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 @@ -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: diff --git a/src/flask_jsonrpc/app.py b/src/flask_jsonrpc/app.py index 9ef4d3a2..f697604c 100644 --- a/src/flask_jsonrpc/app.py +++ b/src/flask_jsonrpc/app.py @@ -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 @@ -46,7 +47,7 @@ from .blueprints import JSONRPCBlueprint -class JSONRPC(JSONRPCDecoratorMixin): +class JSONRPC(JSONRPCDecoratorMixin, JSONRPCErrorHandlerDecoratorMixin): def __init__( self: Self, app: t.Optional[Flask] = None, @@ -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) diff --git a/src/flask_jsonrpc/blueprints.py b/src/flask_jsonrpc/blueprints.py index 131e8dca..fb9407c7 100644 --- a/src/flask_jsonrpc/blueprints.py +++ b/src/flask_jsonrpc/blueprints.py @@ -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+ @@ -40,7 +43,7 @@ from .views import JSONRPCView -class JSONRPCBlueprint(JSONRPCDecoratorMixin): +class JSONRPCBlueprint(JSONRPCDecoratorMixin, JSONRPCErrorHandlerDecoratorMixin): def __init__( self: Self, name: str, @@ -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) diff --git a/src/flask_jsonrpc/exceptions.py b/src/flask_jsonrpc/exceptions.py index 20020805..88309629 100644 --- a/src/flask_jsonrpc/exceptions.py +++ b/src/flask_jsonrpc/exceptions.py @@ -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. diff --git a/src/flask_jsonrpc/handlers.py b/src/flask_jsonrpc/handlers.py new file mode 100644 index 00000000..5b5a421e --- /dev/null +++ b/src/flask_jsonrpc/handlers.py @@ -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 diff --git a/src/flask_jsonrpc/site.py b/src/flask_jsonrpc/site.py index 106e03c8..c5ab2442 100644 --- a/src/flask_jsonrpc/site.py +++ b/src/flask_jsonrpc/site.py @@ -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' @@ -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 @@ -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]]]]: @@ -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), diff --git a/tests/integration/test_app.py b/tests/integration/test_app.py index 22640b81..0bb3225d 100644 --- a/tests/integration/test_app.py +++ b/tests/integration/test_app.py @@ -597,7 +597,6 @@ def test_app_with_pythonclass(self: Self) -> None: 'method': 'jsonrpc.createColor', 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, rv.json()) @@ -605,7 +604,6 @@ def test_app_with_pythonclass(self: Self) -> None: rv = self.requests.post( API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createColor', 'params': {'color': {'name': 'Red'}}}, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(400, rv.status_code) data = rv.json() @@ -624,7 +622,6 @@ def test_app_with_pythonclass(self: Self) -> None: 'method': 'jsonrpc.createManyColor', 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}, {'name': 'Red', 'tag': 'bad'}]}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -644,7 +641,6 @@ def test_app_with_pythonclass(self: Self) -> None: 'method': 'jsonrpc.createManyColor', 'params': {'colors': [{'name': 'Blue', 'tag': 'good'}], 'color': {'name': 'Red', 'tag': 'bad'}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -667,7 +663,6 @@ def test_app_with_pythonclass(self: Self) -> None: {'name': 'Green', 'tag': 'yay'}, ], }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -691,7 +686,6 @@ def test_app_with_pythonclass(self: Self) -> None: 'method': 'jsonrpc.createManyFixColor', 'params': {'colors': {'1': {'name': 'Blue', 'tag': 'good'}}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -706,27 +700,46 @@ def test_app_with_pythonclass(self: Self) -> None: 'method': 'jsonrpc.removeColor', 'params': {'color': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Blue', 'tag': 'good'}}, rv.json()) rv = self.requests.post( - API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': {'color': None}}, - headers={'Content-Type': 'application/json'}, + API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': {'color': None}} ) self.assertEqual(200, rv.status_code) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) rv = self.requests.post( - API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': []}, - headers={'Content-Type': 'application/json'}, + API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeColor', 'params': []} ) self.assertEqual(200, rv.status_code) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) + rv = self.requests.post( + API_URL, + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeColor', + 'params': {'color': {'id': 100, 'name': 'Blue', 'tag': 'good'}}, + }, + ) + self.assertEqual(500, rv.status_code) + self.assertDictEqual( + { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'color_id': 100, 'reason': 'The color with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + }, + rv.json(), + ) + def test_app_with_dataclass(self: Self) -> None: rv = self.requests.post( API_URL, @@ -736,7 +749,6 @@ def test_app_with_dataclass(self: Self) -> None: 'method': 'jsonrpc.createCar', 'params': {'car': {'name': 'Fusca', 'tag': 'blue'}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -746,7 +758,6 @@ def test_app_with_dataclass(self: Self) -> None: rv = self.requests.post( API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createCar', 'params': {'car': {'name': 'Fusca'}}}, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(400, rv.status_code) data = rv.json() @@ -765,7 +776,6 @@ def test_app_with_dataclass(self: Self) -> None: 'method': 'jsonrpc.createManyCar', 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}, {'name': 'Kombi', 'tag': 'yellow'}]}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -785,7 +795,6 @@ def test_app_with_dataclass(self: Self) -> None: 'method': 'jsonrpc.createManyCar', 'params': {'cars': [{'name': 'Fusca', 'tag': 'blue'}], 'car': {'name': 'Kombi', 'tag': 'yellow'}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -808,7 +817,6 @@ def test_app_with_dataclass(self: Self) -> None: {'name': 'Gol', 'tag': 'white'}, ], }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -832,7 +840,6 @@ def test_app_with_dataclass(self: Self) -> None: 'method': 'jsonrpc.createManyFixCar', 'params': {'cars': {'1': {'name': 'Fusca', 'tag': 'blue'}}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -847,7 +854,6 @@ def test_app_with_dataclass(self: Self) -> None: 'method': 'jsonrpc.removeCar', 'params': {'car': {'id': 1, 'name': 'Fusca', 'tag': 'blue'}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -855,20 +861,38 @@ def test_app_with_dataclass(self: Self) -> None: ) rv = self.requests.post( - API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': {'car': None}}, - headers={'Content-Type': 'application/json'}, + API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': {'car': None}} ) self.assertEqual(200, rv.status_code) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) + rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': []}) + self.assertEqual(200, rv.status_code) + self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) + rv = self.requests.post( API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removeCar', 'params': []}, - headers={'Content-Type': 'application/json'}, + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeCar', + 'params': {'car': {'id': 100, 'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + self.assertEqual(500, rv.status_code) + self.assertDictEqual( + { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'car_id': 100, 'reason': 'The car with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + }, + rv.json(), ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) def test_app_with_pydantic(self: Self) -> None: rv = self.requests.post( @@ -879,15 +903,12 @@ def test_app_with_pydantic(self: Self) -> None: 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve', 'tag': 'dog'}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, rv.json()) rv = self.requests.post( - API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve'}}}, - headers={'Content-Type': 'application/json'}, + API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.createPet', 'params': {'pet': {'name': 'Eve'}}} ) self.assertEqual(400, rv.status_code) self.assertDictEqual( @@ -919,7 +940,6 @@ def test_app_with_pydantic(self: Self) -> None: 'method': 'jsonrpc.createManyPet', 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}, {'name': 'Lou', 'tag': 'dog'}]}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -939,7 +959,6 @@ def test_app_with_pydantic(self: Self) -> None: 'method': 'jsonrpc.createManyPet', 'params': {'pets': [{'name': 'Eve', 'tag': 'dog'}], 'pet': {'name': 'Lou', 'tag': 'dog'}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -962,7 +981,6 @@ def test_app_with_pydantic(self: Self) -> None: {'name': 'Tequila', 'tag': 'cat'}, ], }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual( @@ -986,7 +1004,6 @@ def test_app_with_pydantic(self: Self) -> None: 'method': 'jsonrpc.createManyFixPet', 'params': {'pets': {'1': {'name': 'Eve', 'tag': 'dog'}}}, }, - headers={'Content-Type': 'application/json'}, ) self.assertEqual(200, rv.status_code) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': [{'id': 1, 'name': 'Eve', 'tag': 'dog'}]}, rv.json()) @@ -1004,20 +1021,38 @@ def test_app_with_pydantic(self: Self) -> None: self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': {'id': 1, 'name': 'Eve', 'tag': 'dog'}}, rv.json()) rv = self.requests.post( - API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': {'pet': None}}, - headers={'Content-Type': 'application/json'}, + API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': {'pet': None}} ) self.assertEqual(200, rv.status_code) self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) + rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': []}) + self.assertEqual(200, rv.status_code) + self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) + rv = self.requests.post( API_URL, - json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.removePet', 'params': []}, - headers={'Content-Type': 'application/json'}, + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removePet', + 'params': {'pet': {'id': 100, 'name': 'Lou', 'tag': 'dog'}}, + }, + ) + self.assertEqual(500, rv.status_code) + self.assertDictEqual( + { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'pet_id': 100, 'reason': 'The pet with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + }, + rv.json(), ) - self.assertEqual(200, rv.status_code) - self.assertDictEqual({'id': 1, 'jsonrpc': '2.0', 'result': None}, rv.json()) def test_system_describe(self: Self) -> None: rv = self.requests.post(API_URL, json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) diff --git a/tests/test_apps/app/__init__.py b/tests/test_apps/app/__init__.py index 9db1745d..2348406b 100644 --- a/tests/test_apps/app/__init__.py +++ b/tests/test_apps/app/__init__.py @@ -69,6 +69,24 @@ def __init__(self: Self, id: int, name: str, tag: str) -> None: self.id = id +class ColorError: + def __init__(self: Self, color_id: int, reason: str) -> None: + self.color_id = color_id + self.reason = reason + + +class ColorException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class ColorNotFoundException(ColorException): + def __init__(self: Self, message: str, color_error: ColorError) -> None: + super().__init__(message) + self.message = message + self.color_error = color_error + + @dataclass class NewCar: name: str @@ -80,6 +98,24 @@ class Car(NewCar): id: int +@dataclass +class CarError: + car_id: int + reason: str + + +class CarException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class CarNotFoundException(CarException): + def __init__(self: Self, message: str, car_error: CarError) -> None: + super().__init__(message) + self.message = message + self.car_error = car_error + + class NewPet(BaseModel): name: str tag: str @@ -89,6 +125,23 @@ class Pet(NewPet): id: int +class PetError(BaseModel): + pet_id: int + reason: str + + +class PetException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class PetNotFoundException(PetException): + def __init__(self: Self, message: str, pet_error: PetError) -> None: + super().__init__(message) + self.message = message + self.pet_error = pet_error + + class App: def index(self: Self, name: str = 'Flask JSON-RPC') -> str: return f'Hello {name}' @@ -133,6 +186,19 @@ def create_app(test_config: t.Optional[t.Dict[str, t.Any]] = None) -> Flask: # jsonrpc = JSONRPC(flask_app, '/api', enable_web_browsable_api=True) + @jsonrpc.errorhandler(ColorNotFoundException) + def handle_color_not_found_exc(exc: ColorNotFoundException) -> ColorError: + return exc.color_error + + def handle_pet_not_found_exc(exc: PetNotFoundException) -> PetError: + return exc.pet_error + + jsonrpc.register_error_handler(PetNotFoundException, handle_pet_not_found_exc) + + @jsonrpc.errorhandler(CarNotFoundException) + def handle_car_not_found_exc(exc: CarNotFoundException) -> CarError: + return exc.car_error + # pylint: disable=W0612 @jsonrpc.method('jsonrpc.greeting') def greeting(name: str = 'Flask JSON-RPC') -> str: @@ -207,6 +273,18 @@ def mixin_not_validate(s, t: int, u, v: str, x, z): # noqa: ANN001,ANN202 def no_return(_string: t.Optional[str] = None) -> t.NoReturn: raise ValueError('no return') + @jsonrpc.method('jsonrpc.invalidUnion1') + def invalid_union_1(color: t.Union[Color, NewColor]) -> t.Union[Color, NewColor]: + return color + + @jsonrpc.method('jsonrpc.invalidUnion2') + def invalid_union_2(color: t.Union[Color, NewColor, None] = None) -> t.Union[Color, NewColor, None]: + return color + + @jsonrpc.method('jsonrpc.literalType') + def literal_type(x: t.Literal['X']) -> t.Literal['X']: + return x + @jsonrpc.method('jsonrpc.createColor') def create_color(color: NewColor) -> Color: return Color(id=1, name=color.name, tag=color.tag) @@ -224,20 +302,13 @@ def create_many_fix_colors(colors: t.Dict[str, NewPet]) -> t.List[Color]: @jsonrpc.method('jsonrpc.removeColor') def remove_color(color: t.Optional[Color] = None) -> t.Optional[Color]: + if color is not None and color.id > 10: + raise ColorNotFoundException( + 'Color not found', + ColorError(color_id=color.id, reason='The color with an ID greater than 10 does not exist.'), + ) return color - @jsonrpc.method('jsonrpc.invalidUnion1') - def invalid_union_1(color: t.Union[Color, NewColor]) -> t.Union[Color, NewColor]: - return color - - @jsonrpc.method('jsonrpc.invalidUnion2') - def invalid_union_2(color: t.Union[Color, NewColor, None] = None) -> t.Union[Color, NewColor, None]: - return color - - @jsonrpc.method('jsonrpc.literalType') - def literal_type(x: t.Literal['X']) -> t.Literal['X']: - return x - @jsonrpc.method('jsonrpc.createPet') def create_pet(pet: NewPet) -> Pet: return Pet(id=1, name=pet.name, tag=pet.tag) @@ -255,6 +326,10 @@ def create_many_fix_pets(pets: t.Dict[str, NewPet]) -> t.List[Pet]: @jsonrpc.method('jsonrpc.removePet') def remove_pet(pet: t.Optional[Pet] = None) -> t.Optional[Pet]: + if pet is not None and pet.id > 10: + raise PetNotFoundException( + 'Pet not found', PetError(pet_id=pet.id, reason='The pet with an ID greater than 10 does not exist.') + ) return pet @jsonrpc.method('jsonrpc.createCar') @@ -274,6 +349,10 @@ def create_many_fix_cars(cars: t.Dict[str, NewCar]) -> t.List[Car]: @jsonrpc.method('jsonrpc.removeCar') def remove_car(car: t.Optional[Car] = None) -> t.Optional[Car]: + if car is not None and car.id > 10: + raise CarNotFoundException( + 'Car not found', CarError(car_id=car.id, reason='The car with an ID greater than 10 does not exist.') + ) return car class_app = App() diff --git a/tests/test_apps/async_app/__init__.py b/tests/test_apps/async_app/__init__.py index 208605ce..5dd8e7b3 100644 --- a/tests/test_apps/async_app/__init__.py +++ b/tests/test_apps/async_app/__init__.py @@ -70,6 +70,24 @@ def __init__(self: Self, id: int, name: str, tag: str) -> None: self.id = id +class ColorError: + def __init__(self: Self, color_id: int, reason: str) -> None: + self.color_id = color_id + self.reason = reason + + +class ColorException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class ColorNotFoundException(ColorException): + def __init__(self: Self, message: str, color_error: ColorError) -> None: + super().__init__(message) + self.message = message + self.color_error = color_error + + @dataclass class NewCar: name: str @@ -81,6 +99,24 @@ class Car(NewCar): id: int +@dataclass +class CarError: + car_id: int + reason: str + + +class CarException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class CarNotFoundException(CarException): + def __init__(self: Self, message: str, car_error: CarError) -> None: + super().__init__(message) + self.message = message + self.car_error = car_error + + class NewPet(BaseModel): name: str tag: str @@ -90,6 +126,23 @@ class Pet(NewPet): id: int +class PetError(BaseModel): + pet_id: int + reason: str + + +class PetException(Exception): + def __init__(self: Self, *args: object) -> None: + super().__init__(*args) + + +class PetNotFoundException(PetException): + def __init__(self: Self, message: str, pet_error: PetError) -> None: + super().__init__(message) + self.message = message + self.pet_error = pet_error + + class App: async def index(self: Self, name: str = 'Flask JSON-RPC') -> str: await asyncio.sleep(0) @@ -126,6 +179,7 @@ async def fails(self: Self, n: int) -> int: def async_jsonrpc_decorator(fn: t.Callable[..., str]) -> t.Callable[..., str]: @functools.wraps(fn) async def wrapped(*args, **kwargs) -> str: # noqa: ANN002,ANN003 + await asyncio.sleep(0) rv = await fn(*args, **kwargs) return f'{rv} from decorator, ;)' @@ -140,6 +194,21 @@ def create_async_app(test_config: t.Optional[t.Dict[str, t.Any]] = None) -> Flas jsonrpc = JSONRPC(flask_app, '/api', enable_web_browsable_api=True) + @jsonrpc.errorhandler(ColorNotFoundException) + async def handle_color_not_found_exc(exc: ColorNotFoundException) -> ColorError: + await asyncio.sleep(0) + return exc.color_error + + async def handle_pet_not_found_exc(exc: PetNotFoundException) -> PetError: + await asyncio.sleep(0) + return exc.pet_error + + jsonrpc.register_error_handler(PetNotFoundException, handle_pet_not_found_exc) + + @jsonrpc.errorhandler(CarNotFoundException) + async def handle_car_not_found_exc(exc: CarNotFoundException) -> CarError: + return exc.car_error + # pylint: disable=W0612 @jsonrpc.method('jsonrpc.greeting') async def greeting(name: str = 'Flask JSON-RPC') -> str: @@ -227,6 +296,21 @@ async def no_return(_string: t.Optional[str] = None) -> t.NoReturn: await asyncio.sleep(0) raise ValueError('no return') + @jsonrpc.method('jsonrpc.invalidUnion1') + async def invalid_union_1(color: t.Union[Color, NewColor]) -> t.Union[Color, NewColor]: + await asyncio.sleep(0) + return color + + @jsonrpc.method('jsonrpc.invalidUnion2') + async def invalid_union_2(color: t.Union[Color, NewColor, None] = None) -> t.Union[Color, NewColor, None]: + await asyncio.sleep(0) + return color + + @jsonrpc.method('jsonrpc.literalType') + async def literal_type(x: t.Literal['X']) -> t.Literal['X']: + await asyncio.sleep(0) + return x + @jsonrpc.method('jsonrpc.createColor') async def create_color(color: NewColor) -> Color: await asyncio.sleep(0) @@ -234,10 +318,10 @@ async def create_color(color: NewColor) -> Color: @jsonrpc.method('jsonrpc.createManyColor') async def create_many_colors(colors: t.List[NewColor], color: t.Optional[NewColor] = None) -> t.List[Color]: + await asyncio.sleep(0) new_color = [Color(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(colors)] if color is not None: return new_color + [Color(id=len(colors), name=color.name, tag=color.tag)] - await asyncio.sleep(0) return new_color @jsonrpc.method('jsonrpc.createManyFixColor') @@ -248,23 +332,13 @@ async def create_many_fix_colors(colors: t.Dict[str, NewPet]) -> t.List[Color]: @jsonrpc.method('jsonrpc.removeColor') async def remove_color(color: t.Optional[Color] = None) -> t.Optional[Color]: await asyncio.sleep(0) + if color is not None and color.id > 10: + raise ColorNotFoundException( + 'Color not found', + ColorError(color_id=color.id, reason='The color with an ID greater than 10 does not exist.'), + ) return color - @jsonrpc.method('jsonrpc.invalidUnion1') - async def invalid_union_1(color: t.Union[Color, NewColor]) -> t.Union[Color, NewColor]: - await asyncio.sleep(0) - return color - - @jsonrpc.method('jsonrpc.invalidUnion2') - async def invalid_union_2(color: t.Union[Color, NewColor, None] = None) -> t.Union[Color, NewColor, None]: - await asyncio.sleep(0) - return color - - @jsonrpc.method('jsonrpc.literalType') - async def literal_type(x: t.Literal['X']) -> t.Literal['X']: - await asyncio.sleep(0) - return x - @jsonrpc.method('jsonrpc.createPet') async def create_pet(pet: NewPet) -> Pet: await asyncio.sleep(0) @@ -272,10 +346,10 @@ async def create_pet(pet: NewPet) -> Pet: @jsonrpc.method('jsonrpc.createManyPet') async def create_many_pets(pets: t.List[NewPet], pet: t.Optional[NewPet] = None) -> t.List[Pet]: + await asyncio.sleep(0) new_pets = [Pet(id=i, name=pet.name, tag=pet.tag) for i, pet in enumerate(pets)] if pet is not None: return new_pets + [Pet(id=len(pets), name=pet.name, tag=pet.tag)] - await asyncio.sleep(0) return new_pets @jsonrpc.method('jsonrpc.createManyFixPet') @@ -286,6 +360,10 @@ async def create_many_fix_pets(pets: t.Dict[str, NewPet]) -> t.List[Pet]: @jsonrpc.method('jsonrpc.removePet') async def remove_pet(pet: t.Optional[Pet] = None) -> t.Optional[Pet]: await asyncio.sleep(0) + if pet is not None and pet.id > 10: + raise PetNotFoundException( + 'Pet not found', PetError(pet_id=pet.id, reason='The pet with an ID greater than 10 does not exist.') + ) return pet @jsonrpc.method('jsonrpc.createCar') @@ -309,6 +387,10 @@ async def create_many_fix_cars(cars: t.Dict[str, NewCar]) -> t.List[Car]: @jsonrpc.method('jsonrpc.removeCar') async def remove_car(car: t.Optional[Car] = None) -> t.Optional[Car]: await asyncio.sleep(0) + if car is not None and car.id > 10: + raise CarNotFoundException( + 'Car not found', CarError(car_id=car.id, reason='The car with an ID greater than 10 does not exist.') + ) return car class_app = App() diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 94ed2b3e..316a3022 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -35,6 +35,19 @@ from flask_jsonrpc import JSONRPC, JSONRPCBlueprint +# Python 3.10+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + + +class CustomException(Exception): + def __init__(self: Self, message: str, data: t.Dict[str, t.Any]) -> None: + super().__init__(message) + self.message = message + self.data = data + def test_app_create() -> None: app = Flask('test_app', instance_relative_config=True) @@ -137,6 +150,117 @@ def fn4(s: str) -> str: assert rv.status_code == 200 +def test_app_create_using_error_handler() -> None: + app = Flask('test_app', instance_relative_config=True) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + + @jsonrpc.errorhandler(CustomException) + def handle_custom_exc(exc: CustomException) -> t.Dict[str, t.Any]: + return exc.data + + # pylint: disable=W0612 + @jsonrpc.method('app.index') + def index() -> str: + return 'Welcome to Flask JSON-RPC' + + # pylint: disable=W0612 + @jsonrpc.method('app.errorhandler') + def fn0() -> t.NoReturn: + raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) + + with app.test_client() as client: + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Welcome to Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.errorhandler', 'params': []}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'code': '0000', 'message': 'Flask JSON-RPC'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + +def test_app_create_modular_using_error_handler() -> None: + jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) + + @jsonrpc_api_1.errorhandler(CustomException) + def handle_custom_exc_jsonrpc_api_1(exc: CustomException) -> t.Dict[str, t.Any]: + return f"jsonrpc_api_1: {exc.data['message']}" + + # pylint: disable=W0612 + @jsonrpc_api_1.method('blue1.index') + def index_b1() -> str: + return 'b1 index' + + # pylint: disable=W0612 + @jsonrpc_api_1.method('blue1.errorhandler') + def error_b1() -> t.NoReturn: + raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) + + jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) + + @jsonrpc_api_2.errorhandler(CustomException) + def handle_custom_exc_jsonrpc_api_2(exc: CustomException) -> t.Dict[str, t.Any]: + return f"jsonrpc_api_2: {exc.data['message']}" + + # pylint: disable=W0612 + @jsonrpc_api_2.method('blue2.index') + def index_b2() -> str: + return 'b2 index' + + # pylint: disable=W0612 + @jsonrpc_api_2.method('blue2.errorhandler') + def error_b2() -> t.NoReturn: + raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) + + app = Flask('test_app', instance_relative_config=True) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') + jsonrpc.register_blueprint(app, jsonrpc_api_2, url_prefix='/b2') + + with app.test_client() as client: + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1 index'} + assert rv.status_code == 200 + + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.errorhandler', 'params': []}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': 'jsonrpc_api_1: Flask JSON-RPC', + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2 index'} + assert rv.status_code == 200 + + rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.errorhandler', 'params': []}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': 'jsonrpc_api_2: Flask JSON-RPC', + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + def test_app_create_with_server_name() -> None: app = Flask('test_app', instance_relative_config=True) app.config.update({'SERVER_NAME': 'domain:80'}) diff --git a/tests/unit/test_async_app.py b/tests/unit/test_async_app.py index 3d091eba..476aa775 100644 --- a/tests/unit/test_async_app.py +++ b/tests/unit/test_async_app.py @@ -36,9 +36,22 @@ from flask_jsonrpc import JSONRPC, JSONRPCBlueprint +# Python 3.10+ +try: + from typing import Self +except ImportError: # pragma: no cover + from typing_extensions import Self + pytest.importorskip('asgiref') +class CustomException(Exception): + def __init__(self: Self, message: str, data: t.Dict[str, t.Any]) -> None: + super().__init__(message) + self.message = message + self.data = data + + def test_app_create() -> None: app = Flask('test_app', instance_relative_config=True) jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) @@ -73,7 +86,8 @@ async def fn3(s: str) -> str: # pylint: disable=W0612 @jsonrpc.method('app.fn4', notification=False) - def fn4(s: str) -> str: + async def fn4(s: str) -> str: + await asyncio.sleep(0) return f'Goo {s}' jsonrpc.register(fn3, name='app.fn3') @@ -144,6 +158,126 @@ def fn4(s: str) -> str: assert rv.status_code == 200 +def test_app_create_using_error_handler() -> None: + app = Flask('test_app', instance_relative_config=True) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + + @jsonrpc.errorhandler(CustomException) + async def handle_custom_exc(exc: CustomException) -> t.Dict[str, t.Any]: + await asyncio.sleep(0) + return exc.data + + # pylint: disable=W0612 + @jsonrpc.method('app.index') + async def index() -> str: + await asyncio.sleep(0) + return 'Welcome to Flask JSON-RPC' + + # pylint: disable=W0612 + @jsonrpc.method('app.errorhandler') + async def fn0() -> t.NoReturn: + await asyncio.sleep(0) + raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) + + with app.test_client() as client: + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'Welcome to Flask JSON-RPC'} + assert rv.status_code == 200 + + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'app.errorhandler', 'params': []}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'code': '0000', 'message': 'Flask JSON-RPC'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + +def test_app_create_modular_using_error_handler() -> None: + jsonrpc_api_1 = JSONRPCBlueprint('jsonrpc_api_1', __name__) + + @jsonrpc_api_1.errorhandler(CustomException) + async def handle_custom_exc_jsonrpc_api_1(exc: CustomException) -> t.Dict[str, t.Any]: + await asyncio.sleep(0) + return f"jsonrpc_api_1: {exc.data['message']}" + + # pylint: disable=W0612 + @jsonrpc_api_1.method('blue1.index') + async def index_b1() -> str: + await asyncio.sleep(0) + return 'b1 index' + + # pylint: disable=W0612 + @jsonrpc_api_1.method('blue1.errorhandler') + async def error_b1() -> t.NoReturn: + await asyncio.sleep(0) + raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) + + jsonrpc_api_2 = JSONRPCBlueprint('jsonrpc_api_2', __name__) + + @jsonrpc_api_2.errorhandler(CustomException) + async def handle_custom_exc_jsonrpc_api_2(exc: CustomException) -> t.Dict[str, t.Any]: + await asyncio.sleep(0) + return f"jsonrpc_api_2: {exc.data['message']}" + + # pylint: disable=W0612 + @jsonrpc_api_2.method('blue2.index') + async def index_b2() -> str: + await asyncio.sleep(0) + return 'b2 index' + + # pylint: disable=W0612 + @jsonrpc_api_2.method('blue2.errorhandler') + async def error_b2() -> t.NoReturn: + await asyncio.sleep(0) + raise CustomException('Testing error handler', data={'message': 'Flask JSON-RPC', 'code': '0000'}) + + app = Flask('test_app', instance_relative_config=True) + jsonrpc = JSONRPC(app, '/api', enable_web_browsable_api=True) + jsonrpc.register_blueprint(app, jsonrpc_api_1, url_prefix='/b1') + jsonrpc.register_blueprint(app, jsonrpc_api_2, url_prefix='/b2') + + with app.test_client() as client: + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b1 index'} + assert rv.status_code == 200 + + rv = client.post('/api/b1', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue1.errorhandler', 'params': []}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': 'jsonrpc_api_1: Flask JSON-RPC', + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.index', 'params': []}) + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'b2 index'} + assert rv.status_code == 200 + + rv = client.post('/api/b2', json={'id': 1, 'jsonrpc': '2.0', 'method': 'blue2.errorhandler', 'params': []}) + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': 'jsonrpc_api_2: Flask JSON-RPC', + 'message': 'Server error', + 'name': 'ServerError', + }, + } + assert rv.status_code == 500 + + def test_app_create_with_server_name() -> None: app = Flask('test_app', instance_relative_config=True) app.config.update({'SERVER_NAME': 'domain:80'}) @@ -151,7 +285,8 @@ def test_app_create_with_server_name() -> None: # pylint: disable=W0612 @jsonrpc.method('app.index') - def index() -> str: + async def index() -> str: + await asyncio.sleep(0) return 'Welcome to Flask JSON-RPC' with app.test_client() as client: diff --git a/tests/unit/test_async_client.py b/tests/unit/test_async_client.py index 3dd77ac4..5e84b01e 100644 --- a/tests/unit/test_async_client.py +++ b/tests/unit/test_async_client.py @@ -520,6 +520,62 @@ def test_app_class(async_client: 'FlaskClient') -> None: assert rv.status_code == 500 +def test_app_with_invalid_union(async_client: 'FlaskClient') -> None: + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.invalidUnion1', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.invalidUnion2', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + +def test_app_with_pythontypes(async_client: 'FlaskClient') -> None: + rv = async_client.post( + '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': {'x': 'X'}} + ) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'X'} + + def test_app_with_pythonclass(async_client: 'FlaskClient') -> None: rv = async_client.post( '/api', @@ -634,63 +690,28 @@ def test_app_with_pythonclass(async_client: 'FlaskClient') -> None: assert rv.status_code == 200 assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - -def test_app_with_invalid_union(async_client: 'FlaskClient') -> None: rv = async_client.post( '/api', json={ 'id': 1, 'jsonrpc': '2.0', - 'method': 'jsonrpc.invalidUnion1', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 400 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - - rv = async_client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.invalidUnion2', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + 'method': 'jsonrpc.removeColor', + 'params': {'color': {'id': 100, 'name': 'Blue', 'tag': 'good'}}, }, ) - assert rv.status_code == 400 + assert rv.status_code == 500 assert rv.json == { 'id': 1, 'jsonrpc': '2.0', 'error': { - 'code': -32602, - 'data': { - 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', + 'code': -32000, + 'data': {'color_id': 100, 'reason': 'The color with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', }, } -def test_app_with_pythontypes(async_client: 'FlaskClient') -> None: - rv = async_client.post( - '/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': {'x': 'X'}} - ) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'X'} - - def test_app_with_dataclass(async_client: 'FlaskClient') -> None: rv = async_client.post( '/api', @@ -805,6 +826,27 @@ def test_app_with_dataclass(async_client: 'FlaskClient') -> None: assert rv.status_code == 200 assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeCar', + 'params': {'car': {'id': 100, 'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 500 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'car_id': 100, 'reason': 'The car with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + def test_app_with_pydantic(async_client: 'FlaskClient') -> None: rv = async_client.post( @@ -930,6 +972,27 @@ def test_app_with_pydantic(async_client: 'FlaskClient') -> None: assert rv.status_code == 200 assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + rv = async_client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removePet', + 'params': {'pet': {'id': 100, 'name': 'Lou', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 500 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'pet_id': 100, 'reason': 'The pet with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + def test_app_system_describe(async_client: 'FlaskClient') -> None: rv = async_client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'}) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 9c97c531..7cf2aeb3 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -506,6 +506,60 @@ def test_app_class(client: 'FlaskClient') -> None: assert rv.status_code == 500 +def test_app_with_invalid_union(client: 'FlaskClient') -> None: + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.invalidUnion1', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.invalidUnion2', + 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + }, + ) + assert rv.status_code == 400 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32602, + 'data': { + 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' + }, + 'message': 'Invalid params', + 'name': 'InvalidParamsError', + }, + } + + +def test_app_with_pythontypes(client: 'FlaskClient') -> None: + rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': {'x': 'X'}}) + assert rv.status_code == 200 + assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'X'} + + def test_app_with_pythonclass(client: 'FlaskClient') -> None: rv = client.post( '/api', @@ -620,61 +674,28 @@ def test_app_with_pythonclass(client: 'FlaskClient') -> None: assert rv.status_code == 200 assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} - -def test_app_with_invalid_union(client: 'FlaskClient') -> None: rv = client.post( '/api', json={ 'id': 1, 'jsonrpc': '2.0', - 'method': 'jsonrpc.invalidUnion1', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, - }, - ) - assert rv.status_code == 400 - assert rv.json == { - 'id': 1, - 'jsonrpc': '2.0', - 'error': { - 'code': -32602, - 'data': { - 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', - }, - } - - rv = client.post( - '/api', - json={ - 'id': 1, - 'jsonrpc': '2.0', - 'method': 'jsonrpc.invalidUnion2', - 'params': {'color': {'name': 'Blue', 'tag': 'good'}}, + 'method': 'jsonrpc.removeColor', + 'params': {'color': {'id': 100, 'name': 'Blue', 'tag': 'good'}}, }, ) - assert rv.status_code == 400 + assert rv.status_code == 500 assert rv.json == { 'id': 1, 'jsonrpc': '2.0', 'error': { - 'code': -32602, - 'data': { - 'message': 'the only type of union that is supported is: typing.Union[T, ' 'None] or typing.Optional[T]' - }, - 'message': 'Invalid params', - 'name': 'InvalidParamsError', + 'code': -32000, + 'data': {'color_id': 100, 'reason': 'The color with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', }, } -def test_app_with_pythontypes(client: 'FlaskClient') -> None: - rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'jsonrpc.literalType', 'params': {'x': 'X'}}) - assert rv.status_code == 200 - assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': 'X'} - - def test_app_with_dataclass(client: 'FlaskClient') -> None: rv = client.post( '/api', @@ -787,6 +808,27 @@ def test_app_with_dataclass(client: 'FlaskClient') -> None: assert rv.status_code == 200 assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removeCar', + 'params': {'car': {'id': 100, 'name': 'Fusca', 'tag': 'blue'}}, + }, + ) + assert rv.status_code == 500 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'car_id': 100, 'reason': 'The car with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + def test_app_with_pydantic(client: 'FlaskClient') -> None: rv = client.post( @@ -910,6 +952,27 @@ def test_app_with_pydantic(client: 'FlaskClient') -> None: assert rv.status_code == 200 assert rv.json == {'id': 1, 'jsonrpc': '2.0', 'result': None} + rv = client.post( + '/api', + json={ + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'jsonrpc.removePet', + 'params': {'pet': {'id': 100, 'name': 'Lou', 'tag': 'dog'}}, + }, + ) + assert rv.status_code == 500 + assert rv.json == { + 'id': 1, + 'jsonrpc': '2.0', + 'error': { + 'code': -32000, + 'data': {'pet_id': 100, 'reason': 'The pet with an ID greater than 10 does not exist.'}, + 'message': 'Server error', + 'name': 'ServerError', + }, + } + def test_app_system_describe(client: 'FlaskClient') -> None: rv = client.post('/api', json={'id': 1, 'jsonrpc': '2.0', 'method': 'rpc.describe'})