client/json_rpc.py (186 lines of code) (raw):
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import abc
import dataclasses
import json
from enum import Enum
from json.decoder import JSONDecodeError
from typing import Any, Dict, Optional, Union, Sequence, Mapping
JSON = Dict[str, Any]
class LanguageServerMessageType(Enum):
"""Message type for an LSP warning message."""
WARNING = 2
INFORMATION = 3
class JSONRPCException(Exception, metaclass=abc.ABCMeta):
"""
Base class of all jsonrpc related errors.
"""
@abc.abstractmethod
def error_code(self) -> int:
raise NotImplementedError
class ParseError(JSONRPCException):
"""
An error occurred on the server while parsing the JSON text.
"""
def error_code(self) -> int:
return -32700
class InvalidRequestError(JSONRPCException):
"""
The JSON received is not a valid Request object.
Internally we also raise it when the JSON sent is not a valid Response object.
"""
def error_code(self) -> int:
return -32600
class MethodNotFoundError(JSONRPCException):
"""
The method does not exist / is not available.
"""
def error_code(self) -> int:
return -32601
class InvalidParameterError(JSONRPCException):
"""
Invalid method parameter(s).
"""
def error_code(self) -> int:
return -32602
class InternalError(JSONRPCException):
"""
Internal JSON-RPC error.
"""
def error_code(self) -> int:
return -32603
class JSONRPC(abc.ABC):
@abc.abstractmethod
def json(self) -> JSON:
raise NotImplementedError
def serialize(self) -> str:
return json.dumps(self.json())
def _verify_json_rpc_version(json: JSON) -> None:
json_rpc_version = json.get("jsonrpc")
if json_rpc_version is None:
raise InvalidRequestError(f"Required field `jsonrpc` is missing: {json}")
if json_rpc_version != "2.0":
raise InvalidRequestError(
f"`jsonrpc` is expected to be '2.0' but got '{json_rpc_version}'"
)
def _parse_json_rpc_id(json: JSON) -> Union[int, str, None]:
id = json.get("id")
if id is not None and not isinstance(id, int) and not isinstance(id, str):
raise InvalidRequestError(
f"Request ID must be either an integer or string but got {id}"
)
return id
@dataclasses.dataclass(frozen=True)
class ByPositionParameters:
values: Sequence[object] = dataclasses.field(default_factory=list)
@dataclasses.dataclass(frozen=True)
class ByNameParameters:
values: Mapping[str, object] = dataclasses.field(default_factory=dict)
Parameters = Union[ByPositionParameters, ByNameParameters]
@dataclasses.dataclass(frozen=True)
class Request(JSONRPC):
method: str
id: Union[int, str, None] = None
parameters: Optional[Parameters] = None
def json(self) -> JSON:
parameters = self.parameters
return {
"jsonrpc": "2.0",
"method": self.method,
**({"id": self.id} if self.id is not None else {}),
**({"params": parameters.values} if parameters is not None else {}),
}
@staticmethod
def from_json(request_json: JSON) -> "Request":
"""
Parse a given JSON into a JSON-RPC request.
Raises `InvalidRequestError` and `InvalidParameterError` if the JSON
body is malformed.
"""
_verify_json_rpc_version(request_json)
method = request_json.get("method")
if method is None:
raise InvalidRequestError(
f"Required field `method` is missing: {request_json}"
)
if not isinstance(method, str):
raise InvalidRequestError(
f"`method` is expected to be a string but got {method}"
)
raw_parameters = request_json.get("params")
if raw_parameters is None:
parameters = None
elif isinstance(raw_parameters, list):
parameters = ByPositionParameters(raw_parameters)
elif isinstance(raw_parameters, dict):
parameters = ByNameParameters(raw_parameters)
else:
raise InvalidParameterError(
f"Cannot parse request parameter JSON: {raw_parameters}"
)
id = _parse_json_rpc_id(request_json)
return Request(method=method, id=id, parameters=parameters)
@staticmethod
def from_string(request_string: str) -> "Request":
"""
Parse a given string into a JSON-RPC request.
Raises `ParseError` if the parsing fails. Raises `InvalidRequestError`
and `InvalidParameterError` if the JSON body is malformed.
"""
try:
request_json = json.loads(request_string)
return Request.from_json(request_json)
except JSONDecodeError as error:
message = f"Cannot parse string into JSON: {error}"
raise ParseError(message) from error
@dataclasses.dataclass(frozen=True)
class Response(JSONRPC):
id: Union[int, str, None]
@staticmethod
def from_json(response_json: JSON) -> "Response":
"""
Parse a given JSON into a JSON-RPC response.
Raises `InvalidRequestError` if the JSON body is malformed.
"""
if "result" in response_json:
return SuccessResponse.from_json(response_json)
elif "error" in response_json:
return ErrorResponse.from_json(response_json)
else:
raise InvalidRequestError(
"Either `result` or `error` must be presented in JSON-RPC "
+ f"responses. Got {response_json}."
)
@staticmethod
def from_string(response_string: str) -> "Response":
"""
Parse a given string into a JSON-RPC response.
Raises `ParseError` if the parsing fails. Raises `InvalidRequestError`
if the JSON body is malformed.
"""
try:
response_json = json.loads(response_string)
return Response.from_json(response_json)
except JSONDecodeError as error:
message = f"Cannot parse string into JSON: {error}"
raise ParseError(message) from error
@dataclasses.dataclass(frozen=True)
class SuccessResponse(Response):
result: object
def json(self) -> JSON:
return {
"jsonrpc": "2.0",
**({"id": self.id} if self.id is not None else {}),
"result": self.result,
}
@staticmethod
def from_json(response_json: JSON) -> "SuccessResponse":
"""
Parse a given JSON into a JSON-RPC success response.
Raises `InvalidRequestError` if the JSON body is malformed.
"""
_verify_json_rpc_version(response_json)
result = response_json.get("result")
if result is None:
raise InvalidRequestError(
f"Required field `result` is missing: {response_json}"
)
# FIXME: The `id` field is required for the respnose, but we can't
# enforce it right now since the Pyre server may emit id-less responses
# and that has to be fixed first.
id = _parse_json_rpc_id(response_json)
return SuccessResponse(id=id, result=result)
@dataclasses.dataclass(frozen=True)
class ErrorResponse(Response):
code: int
message: str = ""
data: Optional[object] = None
def json(self) -> JSON:
return {
"jsonrpc": "2.0",
**({"id": self.id} if self.id is not None else {}),
"error": {
"code": self.code,
"message": self.message,
**({"data": self.data} if self.data is not None else {}),
},
}
@staticmethod
def from_json(response_json: JSON) -> "ErrorResponse":
"""
Parse a given JSON into a JSON-RPC error response.
Raises `InvalidRequestError` if the JSON body is malformed.
"""
_verify_json_rpc_version(response_json)
error = response_json.get("error")
if error is None:
raise InvalidRequestError(
f"Required field `error` is missing: {response_json}"
)
if not isinstance(error, dict):
raise InvalidRequestError(f"`error` must be a dict but got {error}")
code = error.get("code")
if code is None:
raise InvalidRequestError(
f"Required field `error.code` is missing: {response_json}"
)
if not isinstance(code, int):
raise InvalidRequestError(
f"`error.code` is expected to be an int but got {code}"
)
message = error.get("message", "")
if not isinstance(message, str):
raise InvalidRequestError(
f"`error.message` is expected to be a string but got {message}"
)
data = error.get("data")
# FIXME: The `id` field is required for the respnose, but we can't
# enforce it right now since the Pyre server may emit id-less responses
# and that has to be fixed first.
id = _parse_json_rpc_id(response_json)
return ErrorResponse(id=id, code=code, message=message, data=data)