Skip to content

Return a response object from methods #148

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

Merged
merged 1 commit into from
Apr 3, 2021
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- Removed "convert camel case" option.
- Removed the custom exceptions. From methods, return an ErrorResponse instead
of raising an exception.
- Methods should now return a Response object, and not raise exceptions.

Refactoring/internal changes:

Expand Down
24 changes: 14 additions & 10 deletions doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,34 +133,38 @@ trim_log_values = yes

## Errors

The library handles most errors related to the JSON-RPC standard.
The library handles some errors related to the JSON-RPC standard, such as
invalid json or invalid json-rpc requests.

To return a custom error response:

```python
from jsonrpcserver.exceptions import InvalidParamsError
from jsonrpcserver.response import Context, InvalidParamsResponse, SuccessResponse

@method
def fruits(color):
def fruits(context: Context, color: str) -> Union[SuccessResponse, InvalidParamsResponse]:
if color not in ("red", "orange", "yellow"):
raise InvalidParamsError("No fruits of that colour")
return InvalidParamsResponse("No fruits of that colour", id=context.request.id)
return SuccessResponse("blue", id=context.request.id)
```

The dispatcher will give the appropriate response:

```python
>>> str(dispatch('{"jsonrpc": "2.0", "method": "fruits", "params": {"color": "blue"}, "id": 1}'))
'{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid parameters"}, "id": 1}'
>>> dispatch('{"jsonrpc": "2.0", "method": "fruits", "params": {"color": "blue"}, "id": 1}')
InvalidParamsResponse(code=-32602, message='Invalid parameters', id=1)
```

To send some other application-defined error response, raise an `ApiError` in a
similar way.
To send some other application-defined error response, return an
`ApiErrorResponse` in a similar way.

```python
from jsonrpcserver.exceptions import ApiError
from jsonrpcserver.response import ApiErrorResponse

@method
def my_method():
if some_condition:
raise ApiError("Can't fulfill the request")
return ApiErrorResponse("Can't fulfill the request")
```

## Async
Expand Down
56 changes: 39 additions & 17 deletions jsonrpcserver/async_dispatcher.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Asynchronous dispatch"""

import asyncio
import collections.abc
import logging
from json import JSONDecodeError
from json import dumps as default_serialize, loads as default_deserialize
from typing import Any, Iterable, Optional, Union, Callable
Expand All @@ -13,53 +15,73 @@
add_handlers,
config,
create_requests,
handle_exceptions,
log_request,
log_response,
remove_handlers,
schema,
validate,
)
from .methods import Method, Methods, global_methods, validate_args, lookup
from .request import Request
from .methods import Methods, global_methods, validate_args
from .request import Request, is_notification
from .response import (
BatchResponse,
ExceptionResponse,
InvalidJSONResponse,
InvalidJSONRPCResponse,
InvalidParamsResponse,
NotificationResponse,
Response,
SuccessResponse,
)


async def call(method: Method, *args: Any, **kwargs: Any) -> Any:
return await validate_args(method, *args, **kwargs)(*args, **kwargs)
async def call(request, method, *args, **kwargs) -> Response:
errors = validate_args(method, *args, **kwargs)
return (
await method(*args, **kwargs)
if not errors
else InvalidParamsResponse(data=errors, id=request.id)
)


async def safe_call(
request: Request, methods: Methods, *, extra: Any, serialize: Callable
) -> Response:
with handle_exceptions(request) as handler:
if isinstance(request.params, list):
result = await call(
lookup(methods, request.method),
try:
result = (
await call(
methods.items[request.method],
*([Context(request=request, extra=extra)] + request.params),
)
else:
result = await call(
lookup(methods, request.method),
if isinstance(request.params, list)
else await call(
methods.items[request.method],
Context(request=request, extra=extra),
**request.params,
)
)
# Ensure value returned from the method is JSON-serializable. If not,
# handle_exception will set handler.response to an ExceptionResponse
serialize(result)
handler.response = SuccessResponse(
result=result, id=request.id, serialize_func=serialize
except asyncio.CancelledError:
# Allow CancelledError from asyncio task cancellation to bubble up. Without
# this, CancelledError is caught and handled, resulting in a "Server error"
# response object from the dispatcher, but because the CancelledError doesn't
# bubble up the rpc_server task doesn't exit. See PR
# https://github.com/bcb/jsonrpcserver/pull/132
raise
except Exception as exc: # Other error inside method - server error
logging.exception(exc)
return ExceptionResponse(exc, id=request.id)
else:
return (
NotificationResponse()
if is_notification(request)
else SuccessResponse(result=result, id=request.id, serialize_func=serialize)
)
return handler.response


async def call_requests(
async def dispatch_requests(
requests: Union[Request, Iterable[Request]],
methods: Methods,
extra: Any,
Expand Down Expand Up @@ -87,7 +109,7 @@ async def dispatch_pure(
return InvalidJSONResponse(data=str(exc))
except ValidationError as exc:
return InvalidJSONRPCResponse(data=None)
return await call_requests(
return await dispatch_requests(
create_requests(deserialized),
methods,
extra=extra,
Expand Down
142 changes: 61 additions & 81 deletions jsonrpcserver/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,16 @@
The dispatch() function takes a JSON-RPC request, logs it, calls the appropriate method,
then logs and returns the response.
"""
import asyncio
import logging
import os
from collections.abc import Iterable
from configparser import ConfigParser
from contextlib import contextmanager
from json import JSONDecodeError
from json import dumps as default_serialize, loads as default_deserialize
from types import SimpleNamespace
from typing import (
Any,
Callable,
Dict,
Generator,
Iterable,
List,
NamedTuple,
Expand All @@ -33,10 +29,9 @@
from pkg_resources import resource_string

from .log import log_
from .methods import Method, Methods, global_methods, validate_args, lookup
from .methods import Methods, global_methods, validate_args
from .request import Request, is_notification, NOID
from .response import (
ApiErrorResponse,
BatchResponse,
ExceptionResponse,
InvalidJSONResponse,
Expand All @@ -45,30 +40,29 @@
MethodNotFoundResponse,
NotificationResponse,
Response,
SuccessResponse,
)
from .exceptions import MethodNotFoundError, InvalidParamsError, ApiError

Context = NamedTuple(
"Context",
[("request", Request), ("extra", Any)],
)

request_logger = logging.getLogger(__name__ + ".request")
response_logger = logging.getLogger(__name__ + ".response")

DEFAULT_REQUEST_LOG_FORMAT = "--> %(message)s"
DEFAULT_RESPONSE_LOG_FORMAT = "<-- %(message)s"

# Prepare the jsonschema validator
schema = default_deserialize(resource_string(__name__, "request-schema.json"))
klass = validator_for(schema)
klass.check_schema(schema)
validator = klass(schema)

DEFAULT_REQUEST_LOG_FORMAT = "--> %(message)s"
DEFAULT_RESPONSE_LOG_FORMAT = "<-- %(message)s"

# Read configuration file
config = ConfigParser(default_section="dispatch")
config.read([".jsonrpcserverrc", os.path.expanduser("~/.jsonrpcserverrc")])

Context = NamedTuple(
"Context",
[("request", Request), ("extra", Any)],
)


def add_handlers() -> Tuple[logging.Handler, logging.Handler]:
# Request handler
Expand Down Expand Up @@ -120,49 +114,30 @@ def validate(request: Union[Dict, List], schema: dict) -> Union[Dict, List]:
return request


def call(method: Method, *args: Any, **kwargs: Any) -> Any:
"""
Validates arguments and then calls the method.

Args:
method: The method to call.
*args, **kwargs: Arguments to the method.

Returns:
The "result" part of the JSON-RPC response (the return value from the method).
"""
return validate_args(method, *args, **kwargs)(*args, **kwargs)
def c(request, method, *args, **kwargs) -> Response:
errors = validate_args(method, *args, **kwargs)
return (
method(*args, **kwargs)
if not errors
else InvalidParamsResponse(data=errors, id=request.id)
)


@contextmanager
def handle_exceptions(request: Request) -> Generator:
handler = SimpleNamespace(response=None)
try:
yield handler
except MethodNotFoundError:
handler.response = MethodNotFoundResponse(id=request.id, data=request.method)
except (InvalidParamsError, AssertionError) as exc:
# InvalidParamsError is raised by validate_args. AssertionError is raised inside
# the methods, however it's better to raise InvalidParamsError inside methods.
# AssertionError will be removed in the next major release.
handler.response = InvalidParamsResponse(id=request.id, data=str(exc))
except ApiError as exc: # Method signals custom error
handler.response = ApiErrorResponse(
str(exc), code=exc.code, data=exc.data, id=request.id
def call(request: Request, method: Callable, *, extra: Any) -> Response:
return (
c(
request,
method,
*([Context(request=request, extra=extra)] + request.params),
)
if isinstance(request.params, list)
else c(
request,
method,
Context(request=request, extra=extra),
**request.params,
)
except asyncio.CancelledError:
# Allow CancelledError from asyncio task cancellation to bubble up. Without
# this, CancelledError is caught and handled, resulting in a "Server error"
# response object from the dispatcher, but because the CancelledError doesn't
# bubble up the rpc_server task doesn't exit. See PR
# https://github.com/bcb/jsonrpcserver/pull/132
raise
except Exception as exc: # Other error inside method - server error
logging.exception(exc)
handler.response = ExceptionResponse(exc, id=request.id)
finally:
if is_notification(request):
handler.response = NotificationResponse()
)


def safe_call(
Expand All @@ -179,28 +154,19 @@ def safe_call(
Returns:
A Response object.
"""
with handle_exceptions(request) as handler:
if isinstance(request.params, list):
result = call(
lookup(methods, request.method),
*([Context(request=request, extra=extra)] + request.params),
)
if request.method in methods.items:
try:
response = call(request, methods.items[request.method], extra=extra)
except Exception as exc: # Other error inside method - server error
logging.exception(exc)
return ExceptionResponse(exc, id=request.id)
else:
result = call(
lookup(methods, request.method),
Context(request=request, extra=extra),
**request.params,
)
# Ensure value returned from the method is JSON-serializable. If not,
# handle_exception will set handler.response to an ExceptionResponse
serialize(result)
handler.response = SuccessResponse(
result=result, id=request.id, serialize_func=serialize
)
return handler.response
return NotificationResponse() if is_notification(request) else response
else:
return MethodNotFoundResponse(data=request.method, id=request.id)


def call_requests(
def dispatch_requests_pure(
requests: Union[Request, Iterable[Request]],
methods: Methods,
*,
Expand Down Expand Up @@ -233,6 +199,19 @@ def call_requests(
)


def dispatch_requests(
requests: Union[Request, Iterable[Request]],
methods: Methods,
*,
extra: Optional[Any] = None,
serialize: Callable = default_serialize,
) -> Response:
"""
Impure (public) version of dispatch_requests_pure - has default values.
"""
return dispatch_requests_pure(requests, methods, extra=extra, serialize=serialize)


def create_requests(
requests: Union[Dict, List[Dict]],
) -> Union[Request, List[Request]]:
Expand Down Expand Up @@ -293,12 +272,13 @@ def dispatch_pure(
return InvalidJSONResponse(data=str(exc))
except ValidationError as exc:
return InvalidJSONRPCResponse(data=None)
return call_requests(
create_requests(deserialized),
methods=methods,
extra=extra,
serialize=serialize,
)
else:
return dispatch_requests_pure(
create_requests(deserialized),
methods=methods,
extra=extra,
serialize=serialize,
)


@apply_config(config)
Expand Down
Loading