Skip to content

Commit 22c78d0

Browse files
authored
Return a response object from methods (#148)
Closes #134
1 parent ecda73e commit 22c78d0

File tree

9 files changed

+201
-263
lines changed

9 files changed

+201
-263
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- Removed "convert camel case" option.
1010
- Removed the custom exceptions. From methods, return an ErrorResponse instead
1111
of raising an exception.
12+
- Methods should now return a Response object, and not raise exceptions.
1213

1314
Refactoring/internal changes:
1415

doc/api.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,34 +133,38 @@ trim_log_values = yes
133133

134134
## Errors
135135

136-
The library handles most errors related to the JSON-RPC standard.
136+
The library handles some errors related to the JSON-RPC standard, such as
137+
invalid json or invalid json-rpc requests.
138+
139+
To return a custom error response:
137140

138141
```python
139-
from jsonrpcserver.exceptions import InvalidParamsError
142+
from jsonrpcserver.response import Context, InvalidParamsResponse, SuccessResponse
140143

141144
@method
142-
def fruits(color):
145+
def fruits(context: Context, color: str) -> Union[SuccessResponse, InvalidParamsResponse]:
143146
if color not in ("red", "orange", "yellow"):
144-
raise InvalidParamsError("No fruits of that colour")
147+
return InvalidParamsResponse("No fruits of that colour", id=context.request.id)
148+
return SuccessResponse("blue", id=context.request.id)
145149
```
146150

147151
The dispatcher will give the appropriate response:
148152

149153
```python
150-
>>> str(dispatch('{"jsonrpc": "2.0", "method": "fruits", "params": {"color": "blue"}, "id": 1}'))
151-
'{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid parameters"}, "id": 1}'
154+
>>> dispatch('{"jsonrpc": "2.0", "method": "fruits", "params": {"color": "blue"}, "id": 1}')
155+
InvalidParamsResponse(code=-32602, message='Invalid parameters', id=1)
152156
```
153157

154-
To send some other application-defined error response, raise an `ApiError` in a
155-
similar way.
158+
To send some other application-defined error response, return an
159+
`ApiErrorResponse` in a similar way.
156160

157161
```python
158-
from jsonrpcserver.exceptions import ApiError
162+
from jsonrpcserver.response import ApiErrorResponse
159163

160164
@method
161165
def my_method():
162166
if some_condition:
163-
raise ApiError("Can't fulfill the request")
167+
return ApiErrorResponse("Can't fulfill the request")
164168
```
165169

166170
## Async

jsonrpcserver/async_dispatcher.py

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Asynchronous dispatch"""
2+
23
import asyncio
34
import collections.abc
5+
import logging
46
from json import JSONDecodeError
57
from json import dumps as default_serialize, loads as default_deserialize
68
from typing import Any, Iterable, Optional, Union, Callable
@@ -13,53 +15,73 @@
1315
add_handlers,
1416
config,
1517
create_requests,
16-
handle_exceptions,
1718
log_request,
1819
log_response,
1920
remove_handlers,
2021
schema,
2122
validate,
2223
)
23-
from .methods import Method, Methods, global_methods, validate_args, lookup
24-
from .request import Request
24+
from .methods import Methods, global_methods, validate_args
25+
from .request import Request, is_notification
2526
from .response import (
2627
BatchResponse,
28+
ExceptionResponse,
2729
InvalidJSONResponse,
2830
InvalidJSONRPCResponse,
31+
InvalidParamsResponse,
32+
NotificationResponse,
2933
Response,
3034
SuccessResponse,
3135
)
3236

3337

34-
async def call(method: Method, *args: Any, **kwargs: Any) -> Any:
35-
return await validate_args(method, *args, **kwargs)(*args, **kwargs)
38+
async def call(request, method, *args, **kwargs) -> Response:
39+
errors = validate_args(method, *args, **kwargs)
40+
return (
41+
await method(*args, **kwargs)
42+
if not errors
43+
else InvalidParamsResponse(data=errors, id=request.id)
44+
)
3645

3746

3847
async def safe_call(
3948
request: Request, methods: Methods, *, extra: Any, serialize: Callable
4049
) -> Response:
41-
with handle_exceptions(request) as handler:
42-
if isinstance(request.params, list):
43-
result = await call(
44-
lookup(methods, request.method),
50+
try:
51+
result = (
52+
await call(
53+
methods.items[request.method],
4554
*([Context(request=request, extra=extra)] + request.params),
4655
)
47-
else:
48-
result = await call(
49-
lookup(methods, request.method),
56+
if isinstance(request.params, list)
57+
else await call(
58+
methods.items[request.method],
5059
Context(request=request, extra=extra),
5160
**request.params,
5261
)
62+
)
5363
# Ensure value returned from the method is JSON-serializable. If not,
5464
# handle_exception will set handler.response to an ExceptionResponse
5565
serialize(result)
56-
handler.response = SuccessResponse(
57-
result=result, id=request.id, serialize_func=serialize
66+
except asyncio.CancelledError:
67+
# Allow CancelledError from asyncio task cancellation to bubble up. Without
68+
# this, CancelledError is caught and handled, resulting in a "Server error"
69+
# response object from the dispatcher, but because the CancelledError doesn't
70+
# bubble up the rpc_server task doesn't exit. See PR
71+
# https://github.com/bcb/jsonrpcserver/pull/132
72+
raise
73+
except Exception as exc: # Other error inside method - server error
74+
logging.exception(exc)
75+
return ExceptionResponse(exc, id=request.id)
76+
else:
77+
return (
78+
NotificationResponse()
79+
if is_notification(request)
80+
else SuccessResponse(result=result, id=request.id, serialize_func=serialize)
5881
)
59-
return handler.response
6082

6183

62-
async def call_requests(
84+
async def dispatch_requests(
6385
requests: Union[Request, Iterable[Request]],
6486
methods: Methods,
6587
extra: Any,
@@ -87,7 +109,7 @@ async def dispatch_pure(
87109
return InvalidJSONResponse(data=str(exc))
88110
except ValidationError as exc:
89111
return InvalidJSONRPCResponse(data=None)
90-
return await call_requests(
112+
return await dispatch_requests(
91113
create_requests(deserialized),
92114
methods,
93115
extra=extra,

jsonrpcserver/dispatcher.py

Lines changed: 61 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,16 @@
44
The dispatch() function takes a JSON-RPC request, logs it, calls the appropriate method,
55
then logs and returns the response.
66
"""
7-
import asyncio
87
import logging
98
import os
109
from collections.abc import Iterable
1110
from configparser import ConfigParser
12-
from contextlib import contextmanager
1311
from json import JSONDecodeError
1412
from json import dumps as default_serialize, loads as default_deserialize
15-
from types import SimpleNamespace
1613
from typing import (
1714
Any,
1815
Callable,
1916
Dict,
20-
Generator,
2117
Iterable,
2218
List,
2319
NamedTuple,
@@ -33,10 +29,9 @@
3329
from pkg_resources import resource_string
3430

3531
from .log import log_
36-
from .methods import Method, Methods, global_methods, validate_args, lookup
32+
from .methods import Methods, global_methods, validate_args
3733
from .request import Request, is_notification, NOID
3834
from .response import (
39-
ApiErrorResponse,
4035
BatchResponse,
4136
ExceptionResponse,
4237
InvalidJSONResponse,
@@ -45,30 +40,29 @@
4540
MethodNotFoundResponse,
4641
NotificationResponse,
4742
Response,
48-
SuccessResponse,
4943
)
50-
from .exceptions import MethodNotFoundError, InvalidParamsError, ApiError
44+
45+
Context = NamedTuple(
46+
"Context",
47+
[("request", Request), ("extra", Any)],
48+
)
5149

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

53+
DEFAULT_REQUEST_LOG_FORMAT = "--> %(message)s"
54+
DEFAULT_RESPONSE_LOG_FORMAT = "<-- %(message)s"
55+
5556
# Prepare the jsonschema validator
5657
schema = default_deserialize(resource_string(__name__, "request-schema.json"))
5758
klass = validator_for(schema)
5859
klass.check_schema(schema)
5960
validator = klass(schema)
6061

61-
DEFAULT_REQUEST_LOG_FORMAT = "--> %(message)s"
62-
DEFAULT_RESPONSE_LOG_FORMAT = "<-- %(message)s"
63-
62+
# Read configuration file
6463
config = ConfigParser(default_section="dispatch")
6564
config.read([".jsonrpcserverrc", os.path.expanduser("~/.jsonrpcserverrc")])
6665

67-
Context = NamedTuple(
68-
"Context",
69-
[("request", Request), ("extra", Any)],
70-
)
71-
7266

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

122116

123-
def call(method: Method, *args: Any, **kwargs: Any) -> Any:
124-
"""
125-
Validates arguments and then calls the method.
126-
127-
Args:
128-
method: The method to call.
129-
*args, **kwargs: Arguments to the method.
130-
131-
Returns:
132-
The "result" part of the JSON-RPC response (the return value from the method).
133-
"""
134-
return validate_args(method, *args, **kwargs)(*args, **kwargs)
117+
def c(request, method, *args, **kwargs) -> Response:
118+
errors = validate_args(method, *args, **kwargs)
119+
return (
120+
method(*args, **kwargs)
121+
if not errors
122+
else InvalidParamsResponse(data=errors, id=request.id)
123+
)
135124

136125

137-
@contextmanager
138-
def handle_exceptions(request: Request) -> Generator:
139-
handler = SimpleNamespace(response=None)
140-
try:
141-
yield handler
142-
except MethodNotFoundError:
143-
handler.response = MethodNotFoundResponse(id=request.id, data=request.method)
144-
except (InvalidParamsError, AssertionError) as exc:
145-
# InvalidParamsError is raised by validate_args. AssertionError is raised inside
146-
# the methods, however it's better to raise InvalidParamsError inside methods.
147-
# AssertionError will be removed in the next major release.
148-
handler.response = InvalidParamsResponse(id=request.id, data=str(exc))
149-
except ApiError as exc: # Method signals custom error
150-
handler.response = ApiErrorResponse(
151-
str(exc), code=exc.code, data=exc.data, id=request.id
126+
def call(request: Request, method: Callable, *, extra: Any) -> Response:
127+
return (
128+
c(
129+
request,
130+
method,
131+
*([Context(request=request, extra=extra)] + request.params),
132+
)
133+
if isinstance(request.params, list)
134+
else c(
135+
request,
136+
method,
137+
Context(request=request, extra=extra),
138+
**request.params,
152139
)
153-
except asyncio.CancelledError:
154-
# Allow CancelledError from asyncio task cancellation to bubble up. Without
155-
# this, CancelledError is caught and handled, resulting in a "Server error"
156-
# response object from the dispatcher, but because the CancelledError doesn't
157-
# bubble up the rpc_server task doesn't exit. See PR
158-
# https://github.com/bcb/jsonrpcserver/pull/132
159-
raise
160-
except Exception as exc: # Other error inside method - server error
161-
logging.exception(exc)
162-
handler.response = ExceptionResponse(exc, id=request.id)
163-
finally:
164-
if is_notification(request):
165-
handler.response = NotificationResponse()
140+
)
166141

167142

168143
def safe_call(
@@ -179,28 +154,19 @@ def safe_call(
179154
Returns:
180155
A Response object.
181156
"""
182-
with handle_exceptions(request) as handler:
183-
if isinstance(request.params, list):
184-
result = call(
185-
lookup(methods, request.method),
186-
*([Context(request=request, extra=extra)] + request.params),
187-
)
157+
if request.method in methods.items:
158+
try:
159+
response = call(request, methods.items[request.method], extra=extra)
160+
except Exception as exc: # Other error inside method - server error
161+
logging.exception(exc)
162+
return ExceptionResponse(exc, id=request.id)
188163
else:
189-
result = call(
190-
lookup(methods, request.method),
191-
Context(request=request, extra=extra),
192-
**request.params,
193-
)
194-
# Ensure value returned from the method is JSON-serializable. If not,
195-
# handle_exception will set handler.response to an ExceptionResponse
196-
serialize(result)
197-
handler.response = SuccessResponse(
198-
result=result, id=request.id, serialize_func=serialize
199-
)
200-
return handler.response
164+
return NotificationResponse() if is_notification(request) else response
165+
else:
166+
return MethodNotFoundResponse(data=request.method, id=request.id)
201167

202168

203-
def call_requests(
169+
def dispatch_requests_pure(
204170
requests: Union[Request, Iterable[Request]],
205171
methods: Methods,
206172
*,
@@ -233,6 +199,19 @@ def call_requests(
233199
)
234200

235201

202+
def dispatch_requests(
203+
requests: Union[Request, Iterable[Request]],
204+
methods: Methods,
205+
*,
206+
extra: Optional[Any] = None,
207+
serialize: Callable = default_serialize,
208+
) -> Response:
209+
"""
210+
Impure (public) version of dispatch_requests_pure - has default values.
211+
"""
212+
return dispatch_requests_pure(requests, methods, extra=extra, serialize=serialize)
213+
214+
236215
def create_requests(
237216
requests: Union[Dict, List[Dict]],
238217
) -> Union[Request, List[Request]]:
@@ -293,12 +272,13 @@ def dispatch_pure(
293272
return InvalidJSONResponse(data=str(exc))
294273
except ValidationError as exc:
295274
return InvalidJSONRPCResponse(data=None)
296-
return call_requests(
297-
create_requests(deserialized),
298-
methods=methods,
299-
extra=extra,
300-
serialize=serialize,
301-
)
275+
else:
276+
return dispatch_requests_pure(
277+
create_requests(deserialized),
278+
methods=methods,
279+
extra=extra,
280+
serialize=serialize,
281+
)
302282

303283

304284
@apply_config(config)

0 commit comments

Comments
 (0)