diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa892d..50164a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,16 @@ Errors you return on purpose, with `Error`, `InvalidParams` or by raising clients can't parse anything else. The response is still sent as before. - `@method` gives a `UserWarning` for a name that starts with `rpc.`, because the spec reserves those names. +- A method that returns a plain value instead of `Success(value)` is a common + mistake when upgrading from 4.x. It's now logged with a message that says + what to change, instead of a traceback. The client still gets an Internal + error. +- `serve()` says where it's listening when it starts, including a note when it + listens on every network interface (the default). It logs the line on the + `jsonrpcserver.server` logger, and writes it to stderr if logging isn't + configured. +- Every public function and class has a full docstring, which the new API + reference on the docs site is built from. ### Behaviour changes diff --git a/jsonrpcserver/async_dispatcher.py b/jsonrpcserver/async_dispatcher.py index b5f3589..995a970 100644 --- a/jsonrpcserver/async_dispatcher.py +++ b/jsonrpcserver/async_dispatcher.py @@ -13,6 +13,7 @@ NORESPONSE, BatchTooLargeResponse, Deserialized, + InvalidResultError, create_request, deserialize_request, exception_data, @@ -20,6 +21,7 @@ extract_kwargs, extract_list, get_method, + log_invalid_result, member_id, not_notification, to_response, @@ -52,6 +54,9 @@ async def call( validate_result(result) except JsonRpcError as exc: return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) + except InvalidResultError as exc: + log_invalid_result(request.method, exc, logger) + return Left(InternalErrorResult(exception_data(exc, debug))) except Exception as exc: # Other error inside method - Internal error logger.exception("Method %r raised an exception", request.method) diff --git a/jsonrpcserver/async_main.py b/jsonrpcserver/async_main.py index 306ee33..9d3fedd 100644 --- a/jsonrpcserver/async_main.py +++ b/jsonrpcserver/async_main.py @@ -1,4 +1,10 @@ -"""Async version of main.py. The public async functions.""" +"""The async dispatch functions, for asyncio servers. + +They take the same arguments as the functions in main.py, and are imported from +the package as async_dispatch, async_dispatch_to_serializable and +async_dispatch_to_response. Methods can be async functions or plain ones. The +requests in a batch run concurrently. +""" from typing import Any, Callable, Dict, Iterable, List, Optional, Union, cast @@ -30,6 +36,30 @@ async def dispatch_to_response( debug: bool = False, max_batch_size: Optional[int] = None, ) -> Union[Response, Iterable[Response], None]: + """Dispatch a request and give the response as Response objects. Async. + + The async version of `dispatch_to_response`, imported from the package as + `async_dispatch_to_response`. It takes the same arguments. + + Args: + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + post_process: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. Every request in a + batch runs at the same time, so a limit matters even more here. + + Returns: + A Response for a single request, a list of Responses for a batch, or None + if there's nothing to send back. The type hint says Iterable for a + batch, but it's a list. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + """ check_max_batch_size(max_batch_size) response = await dispatch_to_response_pure( deserializer=deserializer, @@ -54,6 +84,27 @@ async def dispatch_to_serializable( debug: bool = False, max_batch_size: Optional[int] = None, ) -> Union[Dict[str, Any], List[Dict[str, Any]], None]: + """Dispatch a request and give the response as a dict. Async. + + The async version of `dispatch_to_serializable`, imported from the package as + `async_dispatch_to_serializable`. + + Args: + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. + + Returns: + The response as a dict, a list of dicts for a batch, or None if there's + nothing to send back. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + """ return cast( Union[Dict[str, Any], List[Dict[str, Any]], None], await dispatch_to_response( @@ -82,6 +133,37 @@ async def dispatch_to_json( [Union[Dict[str, Any], List[Dict[str, Any]], None]], str ] = default_serializer, ) -> str: + """Dispatch a request and give the response as a JSON string. Async. + + This is `async_dispatch`, the async version of `dispatch`. Methods can be async + functions or plain ones (plain ones work from 5.0.10). A plain method runs on + the event loop, so a slow one holds up every other request. + + Args: + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. + serializer: The same as for `dispatch`. + + Returns: + The response as a JSON string, or "" if there's nothing to send back. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + + Example: + >>> import asyncio + >>> from jsonrpcserver import Result, Success, async_dispatch + >>> async def ping() -> Result: + ... return Success("pong") + >>> request = '{"jsonrpc": "2.0", "method": "ping", "id": 1}' + >>> asyncio.run(async_dispatch(request, {"ping": ping})) + '{"jsonrpc": "2.0", "result": "pong", "id": 1}' + """ response = await dispatch_to_serializable( request, methods, @@ -95,3 +177,4 @@ async def dispatch_to_json( dispatch = dispatch_to_json +"""Another name for `dispatch_to_json`. The package exports it as `async_dispatch`.""" diff --git a/jsonrpcserver/codes.py b/jsonrpcserver/codes.py index 6634f63..76aad52 100644 --- a/jsonrpcserver/codes.py +++ b/jsonrpcserver/codes.py @@ -1,8 +1,20 @@ -"""JSONRPC error codes from http://www.jsonrpc.org/specification#error_object""" +"""The error codes jsonrpcserver sends. + +The first five are defined by the spec: +https://www.jsonrpc.org/specification#error_object +""" ERROR_PARSE_ERROR = -32700 +"""The request isn't valid JSON (or the deserializer raised).""" ERROR_INVALID_REQUEST = -32600 +"""The JSON isn't a valid request object, or a batch is empty or too big.""" ERROR_METHOD_NOT_FOUND = -32601 +"""No method has that name.""" ERROR_INVALID_PARAMS = -32602 +"""The params don't fit the method's signature, or the method said they're invalid.""" ERROR_INTERNAL_ERROR = -32603 +"""The method raised, returned something other than a Result, or its result +could not be serialized. +""" ERROR_SERVER_ERROR = -32000 +"""Something failed in jsonrpcserver itself, outside any method.""" diff --git a/jsonrpcserver/dispatcher.py b/jsonrpcserver/dispatcher.py index 2fe4e0e..b71b813 100644 --- a/jsonrpcserver/dispatcher.py +++ b/jsonrpcserver/dispatcher.py @@ -3,6 +3,7 @@ """ import logging +import reprlib from functools import partial from inspect import iscoroutine, signature from itertools import starmap @@ -112,23 +113,52 @@ def extract_kwargs(request: Request) -> Dict[str, Any]: return request.params if isinstance(request.params, dict) else {} +class InvalidResultError(AssertionError): + """A method returned something other than Success(...) or Error(...).""" + + def __init__(self, result: object): + super().__init__( + f"The method did not return a valid Result (returned {result!r}). " + "Return Success(value) or Error(code, message)." + ) + self.result = result + + def validate_result(result: object) -> None: """Validate the return value from a method. - Raises an AssertionError if the result returned from a method is invalid. + Raises an InvalidResultError, a subclass of AssertionError, if the result returned + from a method is invalid. Returns: None """ # Not an assert statement, because python -O would remove it. + returned = result # Not narrowed by the checks below. error: object = getattr(result, "_error", None) value: object = getattr(result, "_value", None) if not ( (isinstance(result, Left) and isinstance(error, ErrorResult)) or (isinstance(result, Right) and isinstance(value, SuccessResult)) ): - raise AssertionError( - f"The method did not return a valid Result (returned {result!r})" - ) + raise InvalidResultError(returned) + + +def log_invalid_result( + method_name: str, exc: InvalidResultError, log: logging.Logger +) -> None: + """Log a method that returned a plain value, which 4.x allowed, without a traceback. + + The traceback would only point into jsonrpcserver, so the message says what to + change instead. + """ + log.error( + "Method %r returned %s, which is not a Result, so the client got an " + "Internal error. Return Success(value) or Error(code, message). Since 5.0 a " + "plain return value is not enough: " + "https://bensynapse.github.io/jsonrpcserver/migration/", + method_name, + reprlib.repr(exc.result), + ) def exception_data(exc: BaseException, debug: bool) -> Any: @@ -166,6 +196,9 @@ def call( # response. except JsonRpcError as exc: return Left(ErrorResult(code=exc.code, message=exc.message, data=exc.data)) + except InvalidResultError as exc: + log_invalid_result(request.method, exc, logger) + return Left(InternalErrorResult(exception_data(exc, debug))) # Any other uncaught exception inside method - internal error. except Exception as exc: logger.exception("Method %r raised an exception", request.method) diff --git a/jsonrpcserver/exceptions.py b/jsonrpcserver/exceptions.py index 67b8426..06c59a8 100644 --- a/jsonrpcserver/exceptions.py +++ b/jsonrpcserver/exceptions.py @@ -1,4 +1,4 @@ -"""Exceptions""" +"""The exception a method can raise to send an error response.""" from typing import Any @@ -7,9 +7,27 @@ class JsonRpcError(Exception): - """A JsonRpcError exception can be raised from inside a method, as an alternate way - to return an error response. See - https://bensynapse.github.io/jsonrpcserver/methods/#results + """Raise it in a method, or in anything a method calls, to send an error response. + + It's the same as returning `Error(code, message, data)`, but it works from deep + inside other functions. Like `Error`, it's sent as it is, whatever `debug` is. + + Args: + code: The error code, an integer. + message: A short description of the error, as a string. + data: Extra information for the client. If it isn't given, the response has + no `data` member. + + Warns: + UserWarning: If `code` isn't an int or `message` isn't a str. New in 5.0.10. + + Example: + >>> from jsonrpcserver import Result, dispatch_to_serializable + >>> def withdraw(amount: int) -> Result: + ... raise JsonRpcError(2, "Insufficient funds", {"balance": 10}) + >>> request = '{"jsonrpc": "2.0", "method": "withdraw", "params": [5], "id": 1}' + >>> dispatch_to_serializable(request, {"withdraw": withdraw})["error"] + {'code': 2, 'message': 'Insufficient funds', 'data': {'balance': 10}} """ def __init__(self, code: int, message: str, data: Any = NODATA): diff --git a/jsonrpcserver/main.py b/jsonrpcserver/main.py index fade575..382071f 100644 --- a/jsonrpcserver/main.py +++ b/jsonrpcserver/main.py @@ -1,13 +1,13 @@ -"""The public functions. +"""The dispatch functions. -These three public functions all perform the same function of dispatching a JSON-RPC -request, but they each give a different return value. +All three take a JSON-RPC request string, call the methods and give the response. +They differ only in the form of the response: -- dispatch_to_responses: Returns Response(s) (or None for notifications). -- dispatch_to_serializable: Returns a Python dict or list of dicts (or None for - notifications). -- dispatch_to_json/dispatch: Returns a JSON-RPC response string (or an empty string for - notifications). +- dispatch_to_response gives Response objects, or None for a notification. +- dispatch_to_serializable gives a dict or a list of dicts, or None for a + notification. +- dispatch_to_json, also called dispatch, gives a JSON string, or an empty string + for a notification. """ import json @@ -127,40 +127,51 @@ def dispatch_to_response( debug: bool = False, max_batch_size: Optional[int] = None, ) -> Union[Response, List[Response], None]: - """Takes a JSON-RPC request string and dispatches it to method(s), giving Response - namedtuple(s) or None. + """Dispatch a request and give the response as Response objects. - This is a public wrapper around dispatch_to_response_pure, adding globals and - default values to be nicer for end users. + Most code wants `dispatch` (a JSON string) or `dispatch_to_serializable` (dicts) + instead. Use this one to inspect or change responses before they're serialized. + + Each Response is an oslash `Right` holding a `SuccessResponse`, or a `Left` + holding an `ErrorResponse`. oslash has no public way to read them, so check + `isinstance(response, Left)` and read `response._error` or `response._value`. + These attributes are stable for all of 5.x. Printing a Response raises + `TypeError`, because of a bug in oslash; print `to_dict(response)` instead. Args: request: The JSON-RPC request string. - methods: Dictionary of methods that can be called - mapping of function names to - functions. If not passed, uses the internal global_methods dict which is - populated with the @method decorator. - context: If given, will be passed as the first argument to methods. - deserializer: Function that deserializes the request string. - validator: Function that validates the JSON-RPC request. The function should - raise an exception if the request is invalid. Batch members are validated - individually, with nested arrays rejected before calling the validator. - To disable validation, pass lambda _: None. - post_process: Function that will be applied to Responses. - debug: If True, the error response for an uncaught exception in a method - includes the exception message in "data". The default leaves "data" out, - because exception messages can contain passwords, file paths and other - details a client shouldn't see. Only turn this on in development. The - exception is logged either way. + methods: The methods requests can call, as a dict (or any mapping) of names + to functions. The default is the dict that `@method` fills in, + `jsonrpcserver.methods.global_methods`. + context: If given, it's passed as the first argument to every method. The + client can't see or set it. + deserializer: The function that parses the request string. The default is + `json.loads`. If it raises, the client gets a -32700 Parse error whose + `data` is the exception message. + validator: The function that checks a parsed request against the JSON-RPC + spec. It should raise an exception, of any kind, if the request is + invalid. In a batch it's called once for each request (new in 5.0.10). + The default checks against a JSON schema. `lambda _: None` turns + validation off. + post_process: A function applied to each Response before it's returned. + debug: If True, the error response for an exception a method doesn't catch + includes the exception message in `data`. The default leaves `data` + out, because exception messages can contain passwords, file paths and + other details a client shouldn't see. The exception is logged either + way. New in 5.0.10. max_batch_size: The most requests a batch may hold. A bigger batch gets a - single Invalid request response and none of it is dispatched. The default, - None, means no limit. Every member costs validation time, so a server - open to the internet should set one, such as 100. + single -32600 Invalid request response, and none of it is run. The + default, None, means no limit. A server open to the internet should set + one, such as 100. New in 5.0.10. Returns: - A Response, list of Responses or None. + A Response for a single request, a list of Responses for a batch, or None + if there's nothing to send back (a notification, or a batch of only + notifications). With `post_process`, whatever it returns for each. - Examples: - >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}') - '{"jsonrpc": "2.0", "result": "pong", "id": 1}' + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. Requests never + raise: a bad request or a failing method gives an error response. """ check_max_batch_size(max_batch_size) response = dispatch_to_response_pure( @@ -186,10 +197,35 @@ def dispatch_to_serializable( debug: bool = False, max_batch_size: Optional[int] = None, ) -> Union[Dict[str, Any], List[Dict[str, Any]], None]: - """Takes a JSON-RPC request string and dispatches it to method(s), giving responses - as dicts (or None). + """Dispatch a request and give the response as a dict. + + Use it when your framework serializes the response itself, such as a Django + `JsonResponse`, or when you want to inspect it. + + Args: + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. - The arguments are the same as dispatch_to_response, apart from post_process. + Returns: + The response as a dict, a list of dicts for a batch, or None if there's + nothing to send back. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + + Example: + >>> from jsonrpcserver import Result, Success, dispatch_to_serializable + >>> def ping() -> Result: + ... return Success("pong") + >>> dispatch_to_serializable( + ... '{"jsonrpc": "2.0", "method": "ping", "id": 1}', methods={"ping": ping} + ... ) + {'jsonrpc': '2.0', 'result': 'pong', 'id': 1} """ return cast( Union[Dict[str, Any], List[Dict[str, Any]], None], @@ -219,17 +255,40 @@ def dispatch_to_json( [Union[Dict[str, Any], List[Dict[str, Any]], str]], str ] = default_serializer, ) -> str: - """Takes a JSON-RPC request string and dispatches it to method(s), giving a JSON-RPC - response string. + """Dispatch a request and give the response as a JSON string. - This is the main public method, it goes through the entire JSON-RPC process - it's a - function from JSON-RPC request string to JSON-RPC response string. + This is `dispatch`, the function most code uses. Send the string back to the + client. An empty string means there's nothing to send back: the request was a + notification, or a batch of only notifications. Over HTTP, send status 204 with + no body for that. Args: - serializer: A function to serialize a Python object to json. The default is - json.dumps with allow_nan=False. If it raises for a response (say the - method returned a datetime), that response becomes an Internal error. - The rest: The same as dispatch_to_response. + request: The JSON-RPC request string. + methods: The same as for `dispatch_to_response`. + context: The same as for `dispatch_to_response`. + deserializer: The same as for `dispatch_to_response`. + validator: The same as for `dispatch_to_response`. + debug: The same as for `dispatch_to_response`. + max_batch_size: The same as for `dispatch_to_response`. + serializer: The function that turns the response into a string. The + default is `json.dumps` with `allow_nan=False`, so a result holding NaN + or Infinity gives an Internal error instead of invalid JSON (new in + 5.0.10). If the serializer raises for a response, say because the + method returned a `datetime`, that response becomes an Internal error + and the rest of a batch is sent as usual. + + Returns: + The response as a JSON string, or "" if there's nothing to send back. + + Raises: + ValueError: If `max_batch_size` isn't None or a positive int. + + Example: + >>> from jsonrpcserver import Result, Success, dispatch + >>> def ping() -> Result: + ... return Success("pong") + >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping}) + '{"jsonrpc": "2.0", "result": "pong", "id": 1}' """ response = dispatch_to_serializable( request, @@ -245,5 +304,5 @@ def dispatch_to_json( return "" if response is None else serialize(serializer, response, debug) -# "dispatch" aliases dispatch_to_json. dispatch = dispatch_to_json +"""Another name for `dispatch_to_json`, and the one most code uses.""" diff --git a/jsonrpcserver/methods.py b/jsonrpcserver/methods.py index dffbfee..4254ab2 100644 --- a/jsonrpcserver/methods.py +++ b/jsonrpcserver/methods.py @@ -1,15 +1,16 @@ -"""A method is a Python function that can be called by a JSON-RPC request. +"""Methods: the functions a JSON-RPC request can call. -They're held in a dict, a mapping of function names to functions. +The dispatch functions look methods up in a dict of names to functions. The @method +decorator adds a function to global_methods, the dict they use by default. To use +your own dict instead, pass it as the methods argument: -The @method decorator adds a method to jsonrpcserver's internal global_methods dict. -Alternatively pass your own dictionary of methods to `dispatch` with the methods param. + dispatch(request) # the functions registered with @method + dispatch(request, methods={"ping": ping}) # only the functions in this dict - >>> dispatch(request) # Uses the internal collection of funcs added with @method - >>> dispatch(request, methods={"ping": lambda: "pong"}) # Custom collection +Either way, a method returns Success(...) or Error(...), not a plain value. -Methods can take either positional or named arguments, but not both. This is a -limitation of JSON-RPC. +A request's params are either a list (positional arguments) or an object (named +arguments), not both. That's a JSON-RPC rule. """ import warnings @@ -26,6 +27,11 @@ MethodsArgument = Mapping[str, AnyMethod] global_methods: Dict[str, AnyMethod] = {} +"""The methods registered with `@method`, as a dict of names to functions. + +The dispatch functions use it when they're called without `methods`. It's shared by +the whole process, so every module that uses `@method` adds to it. +""" F = TypeVar("F", bound=Callable[..., Any]) @@ -39,18 +45,36 @@ def method(f: None = None, name: Optional[str] = None) -> Callable[[F], F]: ... def method(f: Optional[F] = None, name: Optional[str] = None) -> Any: - """A decorator to add a function into jsonrpcserver's internal global_methods dict. - The global_methods dict will be used by default unless a methods argument is passed - to `dispatch`. + """Register a function as a JSON-RPC method. + + The function is added to `global_methods`, which the dispatch functions use when + they're called without `methods`. It's returned unchanged, so you can still call + it yourself, and type checkers see its real signature (from 5.0.10). A method + with the same name as an earlier one replaces it, without a warning. + + Use it with or without arguments: + + ```python + @method + def ping() -> Result: + return Success("pong") + + @method(name="sum") + def add(a: int, b: int) -> Result: + return Success(a + b) + ``` - Functions can be renamed by passing a name argument: + Args: + f: The function. Leave it out to pass `name`. + name: The name requests use to call the method. The default is the + function's name. - @method(name="bar") - def foo(): - ... + Returns: + The function itself, or, when called with only `name`, a decorator. - The decorated function is returned unchanged, so type checkers still see its real - signature. A method with the same name as an earlier one replaces it. + Warns: + UserWarning: If the name starts with "rpc.". The JSON-RPC spec reserves those + names. New in 5.0.10. """ def decorator(func: F) -> F: diff --git a/jsonrpcserver/response.py b/jsonrpcserver/response.py index 694b891..c93c9a3 100644 --- a/jsonrpcserver/response.py +++ b/jsonrpcserver/response.py @@ -1,6 +1,7 @@ -"""The response data types. +"""Responses: what dispatch_to_response gives, and how to turn them into dicts. -https://www.jsonrpc.org/specification#response_object +A Response is a Result plus the request's id +(https://www.jsonrpc.org/specification#response_object). """ import warnings @@ -20,17 +21,17 @@ class SuccessResponse(NamedTuple): - """It would be nice to subclass Success here, adding only id. But it's not possible - to easily subclass NamedTuples in Python 3.6. (I believe it can be done in 3.8.) - """ + """A successful response: the method's result and the request's id.""" result: Any id: Any class ErrorResponse(NamedTuple): - """It would be nice to subclass Error here, adding only id. But it's not possible to - easily subclass NamedTuples in Python 3.6. (I believe it can be done in 3.8.) + """An error response: the error's code, message and data, and the request's id. + + `data` is `NODATA` when there's no data, so it's left out of the response. `id` + is None when the request couldn't be read, as the spec requires. """ code: int @@ -41,8 +42,16 @@ class ErrorResponse(NamedTuple): # oslash's Either takes the success type first, then the error type. Response = Either[SuccessResponse, ErrorResponse] -# Kept for backward compatibility. Use Response. +"""What `dispatch_to_response` gives for each request. + +An oslash `Either`: a `Right` holding a `SuccessResponse`, or a `Left` holding an +`ErrorResponse`. To read one, check `isinstance(response, Left)`, then read +`response._error` (an `ErrorResponse`) or `response._value` (a `SuccessResponse`). +oslash has no public accessor, but these attributes are stable for all of 5.x. Or turn +it into a dict with `to_dict`. +""" ResponseType = Type[Response] +"""Deprecated: use `Response`. Kept so code written for 5.0.9 keeps working.""" def ParseErrorResponse(data: Any) -> ErrorResponse: # pylint: disable=invalid-name @@ -78,7 +87,7 @@ def ServerErrorResponse(data: Any, id: Any) -> ErrorResponse: def to_error_dict(response: ErrorResponse) -> Dict[str, Any]: - """From ErrorResponse object to dict""" + """Turn an ErrorResponse into a JSON-RPC response dict, leaving out missing data.""" return { "jsonrpc": "2.0", "error": { @@ -92,12 +101,27 @@ def to_error_dict(response: ErrorResponse) -> Dict[str, Any]: def to_success_dict(response: SuccessResponse) -> Dict[str, Any]: - """From SuccessResponse object to dict""" + """Turn a SuccessResponse into a JSON-RPC response dict.""" return {"jsonrpc": "2.0", "result": response.result, "id": response.id} def to_dict(response: Response) -> Dict[str, Any]: - """Serialize either an error or success response object to dict""" + """Turn a Response into a JSON-RPC response dict. + + Args: + response: A Response from `dispatch_to_response`. + + Returns: + The response as a dict, ready for `json.dumps`. + + Example: + >>> from jsonrpcserver import Result, Success, dispatch_to_response + >>> def ping() -> Result: + ... return Success("pong") + >>> request = '{"jsonrpc": "2.0", "method": "ping", "id": 1}' + >>> to_dict(dispatch_to_response(request, {"ping": ping})) + {'jsonrpc': '2.0', 'result': 'pong', 'id': 1} + """ if isinstance(response, Left): return to_error_dict(response._error) success = cast("Right[SuccessResponse, ErrorResponse]", response) @@ -107,7 +131,7 @@ def to_dict(response: Response) -> Dict[str, Any]: def to_serializable( response: Union[Response, List[Response], None], ) -> Union[Deserialized, None]: - """Serialize a response object (or list of them), to a dict, or list of them.""" + """Turn a Response, a list of them or None into a dict, a list of dicts or None.""" if response is None: return None if isinstance(response, List): @@ -129,18 +153,18 @@ def _deprecated(old: str, new: str) -> None: def serialize_error(response: ErrorResponse) -> Dict[str, Any]: - """Deprecated. Use to_error_dict.""" + """Deprecated: use `to_error_dict`. Warns with DeprecationWarning (5.0.10).""" _deprecated("serialize_error", "to_error_dict") return to_error_dict(response) def serialize_success(response: SuccessResponse) -> Dict[str, Any]: - """Deprecated. Use to_success_dict.""" + """Deprecated: use `to_success_dict`. Warns with DeprecationWarning (5.0.10).""" _deprecated("serialize_success", "to_success_dict") return to_success_dict(response) def to_serializable_one(response: Response) -> Dict[str, Any]: - """Deprecated. Use to_dict.""" + """Deprecated: use `to_dict`. Warns with DeprecationWarning (5.0.10).""" _deprecated("to_serializable_one", "to_dict") return to_dict(response) diff --git a/jsonrpcserver/result.py b/jsonrpcserver/result.py index 0d06dac..16c17e5 100644 --- a/jsonrpcserver/result.py +++ b/jsonrpcserver/result.py @@ -1,10 +1,8 @@ -"""Result data types - the results of calling a method. +"""What a method returns: Success, Error or InvalidParams. -Results are the JSON-RPC response objects -(https://www.jsonrpc.org/specification#response_object), minus the "jsonrpc" and "id" -parts - the library takes care of these parts for you. - -The public functions are Success, Error and InvalidParams. +A Result is the "result" or "error" part of a JSON-RPC response object +(https://www.jsonrpc.org/specification#response_object). The library adds the +"jsonrpc" and "id" parts. """ from typing import Any, NamedTuple @@ -19,6 +17,8 @@ class SuccessResult(NamedTuple): + """The value inside the Result that `Success` returns.""" + result: Any = None def __repr__(self) -> str: @@ -26,6 +26,11 @@ def __repr__(self) -> str: class ErrorResult(NamedTuple): + """The value inside the Result that `Error` and `InvalidParams` return. + + `data` is `NODATA` when there's no data, so it's left out of the response. + """ + code: int message: str data: Any = NODATA # The spec says this value may be omitted @@ -40,6 +45,12 @@ def __repr__(self) -> str: # Union of the two valid result types. oslash's Either takes the success type # first, then the error type. Result = Either[SuccessResult, ErrorResult] +"""The return type of a method: what `Success`, `Error` and `InvalidParams` give. + +It's an oslash `Either`: a `Right` holding a `SuccessResult`, or a `Left` holding an +`ErrorResult`. Use it as the return annotation of your methods. You don't need to +look inside it. +""" # Helpers @@ -61,16 +72,79 @@ def InvalidParamsResult(data: Any = NODATA) -> ErrorResult: def Success(result: Any = None) -> Result: + """A successful result. Return it from a method. + + Args: + result: The value for the response's `result` member. It can be anything + the serializer handles. The default, None, is sent as `null`. + + Returns: + A Result holding the value. + + Example: + >>> from jsonrpcserver import Result, Success, dispatch + >>> def ping() -> Result: + ... return Success("pong") + >>> dispatch('{"jsonrpc": "2.0", "method": "ping", "id": 1}', {"ping": ping}) + '{"jsonrpc": "2.0", "result": "pong", "id": 1}' + """ return Right(SuccessResult(result)) def Error(code: int, message: str, data: Any = NODATA) -> Result: + """An error result. Return it from a method to send an error response. + + It's sent as it is, whatever `debug` is, so don't put secrets in it. + + Args: + code: The error code, an integer. The spec reserves -32768 to -32000 for + its own errors, so pick other numbers for yours. + message: A short description of the error, as a string. + data: Extra information for the client, such as details of what went + wrong. If it isn't given, the response has no `data` member. + + Returns: + A Result holding the error. + + Warns: + UserWarning: If `code` isn't an int or `message` isn't a str. The error is + still sent as given. New in 5.0.10. + + Example: + >>> from jsonrpcserver import Error, Result, dispatch_to_serializable + >>> def fail() -> Result: + ... return Error(1, "It failed", {"reason": "example"}) + >>> request = '{"jsonrpc": "2.0", "method": "fail", "id": 1}' + >>> dispatch_to_serializable(request, {"fail": fail})["error"] + {'code': 1, 'message': 'It failed', 'data': {'reason': 'example'}} + """ warn_if_invalid_error(code, message, stacklevel=2) return Left(ErrorResult(code, message, data)) def InvalidParams(data: Any = NODATA) -> Result: - """InvalidParams is a shortcut to save you from having to pass the Invalid Params - JSON-RPC code to Error. + """An Invalid params error: `Error(-32602, "Invalid params", data)`. + + Return it when the arguments have the right shape but a bad value. + jsonrpcserver already sends this error when the arguments don't fit the + method's signature. + + Args: + data: What was wrong, for the client. If it isn't given, the response has no + `data` member. + + Returns: + A Result holding the error. + + Example: + >>> from jsonrpcserver import InvalidParams, Result, Success + >>> from jsonrpcserver import dispatch_to_serializable + >>> def rate(stars: int) -> Result: + ... if stars not in range(1, 6): + ... return InvalidParams("Stars must be 1 to 5") + ... return Success() + >>> request = '{"jsonrpc": "2.0", "method": "rate", "params": [6], "id": 1}' + >>> dispatch_to_serializable(request, {"rate": rate})["error"] + {'code': -32602, 'message': 'Invalid params', 'data': 'Stars must be 1 to 5'} """ return Left(InvalidParamsResult(data)) diff --git a/jsonrpcserver/sentinels.py b/jsonrpcserver/sentinels.py index 0ed0cca..d4a814c 100644 --- a/jsonrpcserver/sentinels.py +++ b/jsonrpcserver/sentinels.py @@ -21,5 +21,8 @@ def __repr__(self) -> str: NOCONTEXT = Sentinel("NoContext") +"""The default for `context`: don't pass a context to methods.""" NODATA = Sentinel("NoData") +"""The default for `data` in `Error` and `JsonRpcError`: leave `data` out.""" NOID = Sentinel("NoId") +"""The id of a notification, which has none.""" diff --git a/jsonrpcserver/server.py b/jsonrpcserver/server.py index ab11a66..715967c 100644 --- a/jsonrpcserver/server.py +++ b/jsonrpcserver/server.py @@ -1,5 +1,4 @@ -"""A simple development server for serving JSON-RPC requests using Python's builtin -http.server module. +"""A development server for trying out methods, built on Python's http.server. It's meant for trying things out. For production, put dispatch behind a real web server or framework. @@ -7,6 +6,7 @@ import json import logging +import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any @@ -60,11 +60,56 @@ def do_POST(self) -> None: self.end_headers() +def listening_message(name: str, port: int) -> str: + """The line serve() shows when it starts.""" + if name in ("", "0.0.0.0"): + return ( + f" * Listening on port {port} on every network interface. This is a " + 'development server. Use serve("localhost", ...) to accept only local ' + "connections." + ) + return f" * Listening on http://{name}:{port}/. This is a development server." + + def serve(name: str = "", port: int = 5000) -> None: - """A simple function to serve HTTP requests. For development only.""" - logger.info(" * Listening on port %s", port) + """Serve the methods registered with `@method` over HTTP. For development only. + + It answers POST requests on any path with `dispatch`, sends 204 No Content for a + notification, and runs each request in its own thread. It runs until the process + is stopped, for example with Ctrl+C. + + When it starts, it logs where it's listening on the `jsonrpcserver.server` + logger. If logging isn't configured, it writes that line to stderr instead + (new in 5.0.10). Each request is logged at INFO level. + + It has no TLS, no authentication and no request size limit, and it doesn't pass + `max_batch_size`. Put `dispatch` behind a real web server or framework in + production. + + Args: + name: The host name or address to listen on. The default, "", listens on + every network interface, so other machines can connect. Pass "localhost" + to accept only local connections. + port: The port to listen on. + + Example: + ```python + from jsonrpcserver import Result, Success, method, serve + + @method + def ping() -> Result: + return Success("pong") + + serve("localhost", 8000) + ``` + """ httpd = ThreadingHTTPServer((name, port), RequestHandler) try: + message = listening_message(name, port) + logger.info("%s", message) + if not logger.hasHandlers() and sys.stderr is not None: + # logging isn't configured, so the line above went nowhere. + print(message, file=sys.stderr, flush=True) httpd.serve_forever() finally: httpd.server_close() diff --git a/tests/test_async_dispatcher.py b/tests/test_async_dispatcher.py index a7378f0..6c35bda 100644 --- a/tests/test_async_dispatcher.py +++ b/tests/test_async_dispatcher.py @@ -94,6 +94,24 @@ async def method() -> Result: assert str(record.exc_info[1]) == "secret detail" +@pytest.mark.asyncio +async def test_plain_return_value_is_logged_with_a_hint( + caplog: pytest.LogCaptureFixture, +) -> None: + async def method() -> str: + return "pong" + + assert await call(Request("ping", [], 1), NOCONTEXT, method) == Left( + ErrorResult(ERROR_INTERNAL_ERROR, "Internal error") + ) + (record,) = caplog.records + assert record.name == "jsonrpcserver.async_dispatcher" + assert record.exc_info is None + assert record.getMessage().startswith( + "Method 'ping' returned 'pong', which is not a Result" + ) + + @pytest.mark.asyncio async def test_dispatch_request() -> None: request = Request("ping", [], 1) diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 46edae0..d645669 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -529,12 +529,42 @@ def not_a_result() -> None: ErrorResponse( ERROR_INTERNAL_ERROR, "Internal error", - "The method did not return a valid Result (returned None)", + "The method did not return a valid Result (returned None). " + "Return Success(value) or Error(code, message).", 1, ) ) +def test_plain_return_value_is_logged_with_a_hint( + caplog: pytest.LogCaptureFixture, +) -> None: + """A 4.x-style method that returns a plain value gets a log line saying what to do, + not a traceback into jsonrpcserver.""" + + def ping() -> str: + return "pong" + + response = dispatch_to_response_pure( + deserializer=default_deserializer, + validator=default_validator, + post_process=identity, + context=NOCONTEXT, + methods={"ping": ping}, + request='{"jsonrpc": "2.0", "method": "ping", "id": 1}', + ) + assert response == Left( + ErrorResponse(ERROR_INTERNAL_ERROR, "Internal error", NODATA, 1) + ) + (record,) = caplog.records + assert record.levelname == "ERROR" + assert record.exc_info is None + assert record.getMessage().startswith( + "Method 'ping' returned 'pong', which is not a Result, so the client got an " + "Internal error. Return Success(value) or Error(code, message)." + ) + + def test_dispatch_to_response_pure_raising_exception() -> None: """Allow raising an exception to return an error.""" diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py new file mode 100644 index 0000000..da95cf3 --- /dev/null +++ b/tests/test_docstrings.py @@ -0,0 +1,31 @@ +"""The docstrings are the API reference, so their examples must run and every +public name must have one.""" + +import doctest +import inspect +from types import ModuleType + +import pytest + +import jsonrpcserver +from jsonrpcserver import async_main, exceptions, main, response, result + +MODULES = [main, async_main, result, exceptions, response] + + +@pytest.mark.parametrize("module", MODULES, ids=lambda m: m.__name__) +def test_docstring_examples(module: ModuleType) -> None: + outcome = doctest.testmod(module, optionflags=doctest.ELLIPSIS) + assert outcome.attempted > 0 + assert outcome.failed == 0 + + +@pytest.mark.parametrize("name", jsonrpcserver.__all__) +def test_every_public_name_has_a_docstring(name: str) -> None: + obj = getattr(jsonrpcserver, name) + if name == "Result": + # A typing alias. Its docstring is an attribute docstring in result.py, + # which the API reference reads from the source. + assert '"""The return type of a method' in inspect.getsource(result) + else: + assert (obj.__doc__ or "").strip(), name diff --git a/tests/test_server.py b/tests/test_server.py index b31ebc5..ae3f530 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -12,7 +12,8 @@ import pytest from jsonrpcserver import Result, Success, method -from jsonrpcserver.server import RequestHandler, serve +from jsonrpcserver import server as server_module +from jsonrpcserver.server import RequestHandler, listening_message, serve @patch("jsonrpcserver.server.ThreadingHTTPServer") @@ -37,6 +38,54 @@ def test_serve_closes_on_interrupt(server: Mock) -> None: server.return_value.server_close.assert_called_once_with() +def test_listening_message_every_interface() -> None: + assert listening_message("", 5000) == ( + " * Listening on port 5000 on every network interface. This is a development " + 'server. Use serve("localhost", ...) to accept only local connections.' + ) + assert listening_message("0.0.0.0", 8000).startswith( + " * Listening on port 8000 on every network interface." + ) + + +def test_listening_message_one_host() -> None: + assert listening_message("localhost", 8000) == ( + " * Listening on http://localhost:8000/. This is a development server." + ) + + +@patch("jsonrpcserver.server.ThreadingHTTPServer") +def test_serve_logs_where_it_listens( + server: Mock, caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str] +) -> None: + with caplog.at_level(logging.INFO, logger="jsonrpcserver.server"): + serve("localhost", 8000) + assert [r.getMessage() for r in caplog.records] == [ + listening_message("localhost", 8000) + ] + # Logging is configured (pytest's handler), so nothing extra goes to stderr. + assert capsys.readouterr().err == "" + + +@patch("jsonrpcserver.server.ThreadingHTTPServer") +def test_serve_prints_where_it_listens_without_logging( + server: Mock, capsys: pytest.CaptureFixture[str] +) -> None: + with patch.object(server_module.logger, "hasHandlers", return_value=False): + serve("localhost", 8000) + assert capsys.readouterr().err == listening_message("localhost", 8000) + "\n" + + +@patch("jsonrpcserver.server.ThreadingHTTPServer") +def test_serve_without_stderr(server: Mock) -> None: + """sys.stderr is None in a PyInstaller app built with --noconsole (#269).""" + with patch.object( + server_module.logger, "hasHandlers", return_value=False + ), patch.object(sys, "stderr", None): + serve("localhost", 8000) + server.return_value.serve_forever.assert_called_once_with() + + @method(name="server_test_ping") def ping() -> Result: return Success("pong")