From 521d2b4a19929a2a88bcff63ea767329b7015980 Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Fri, 3 Dec 2021 16:06:37 +0000 Subject: [PATCH 1/3] Add utils to build web servlets --- matrix_common/json.py | 17 +++++ matrix_common/servlet.py | 151 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 matrix_common/json.py create mode 100644 matrix_common/servlet.py diff --git a/matrix_common/json.py b/matrix_common/json.py new file mode 100644 index 0000000..d32ae6b --- /dev/null +++ b/matrix_common/json.py @@ -0,0 +1,17 @@ +# Copyright 2021 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Dict + +JsonDict = Dict[str, Any] diff --git a/matrix_common/servlet.py b/matrix_common/servlet.py new file mode 100644 index 0000000..b268a28 --- /dev/null +++ b/matrix_common/servlet.py @@ -0,0 +1,151 @@ +# Copyright 2021 The Matrix.org Foundation C.I.C. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools +import json +import logging +from typing import Any, Callable + +from twisted.internet import defer +from twisted.python import failure +from twisted.web import server +from twisted.web.server import Request + +from matrix_common.json import JsonDict + +logger = logging.getLogger(__name__) + + +class MatrixRestError(Exception): + """ + Handled by the jsonwrap wrapper. Any servlets that don't use this + wrapper should catch this exception themselves. + """ + + def __init__(self, httpStatus: int, errcode: str, error: str): + super(Exception, self).__init__(error) + self.httpStatus = httpStatus + self.errcode = errcode + self.error = error + + +def json_servlet_sync(f: Callable[..., JsonDict]) -> Callable[..., bytes]: + @functools.wraps(f) + def inner(self: Any, request: Request, *args: Any, **kwargs: Any) -> bytes: + """ + Runs a web handler function with the given request and parameters, then + converts its result into JSON and returns it. If an error happens, also sets + the HTTP response code. + + Args: + self: The current object. + request: The request to process. + args: The arguments to pass to the function. + kwargs: The keyword arguments to pass to the function. + + Returns: + The JSON payload to send as a response to the request. + """ + try: + request.setHeader("Content-Type", "application/json") + return dict_to_json_bytes(f(self, request, *args, **kwargs)) + except MatrixRestError as e: + request.setResponseCode(e.httpStatus) + return dict_to_json_bytes({"errcode": e.errcode, "error": e.error}) + except Exception: + logger.exception("Exception processing request") + request.setHeader("Content-Type", "application/json") + request.setResponseCode(500) + return dict_to_json_bytes( + { + "errcode": "M_UNKNOWN", + "error": "Internal Server Error", + } + ) + + return inner + + +def json_servlet_async(fn: Callable[..., JsonDict]) -> Callable[..., int]: + async def render( + fn: Callable[..., Any], self: Any, request: Request, **kwargs: Any + ) -> None: + """ + Runs an asynchronous web handler function with the given request and parameters, + then converts its result into JSON bytes and writes it to the request. If an error + happens, also sets the HTTP response code. + + Args: + fn: The handler to run. + self: The current object. + request: The request to process. + args: The arguments to pass to the function. + kwargs: The keyword arguments to pass to the function. + """ + + request.setHeader("Content-Type", "application/json") + try: + result = await fn(self, request, **kwargs) + request.write(dict_to_json_bytes(result)) + except MatrixRestError as e: + request.setResponseCode(e.httpStatus) + request.write(dict_to_json_bytes({"errcode": e.errcode, "error": e.error})) + except Exception: + f = failure.Failure() + logger.error("Request processing failed: %r, %s", f, f.getTraceback()) + request.setResponseCode(500) + request.write( + dict_to_json_bytes( + {"errcode": "M_UNKNOWN", "error": "Internal Server Error"} + ) + ) + request.finish() + + @functools.wraps(fn) + def inner(*args: Any, **kwargs: Any) -> int: + """ + Runs an asynchronous web handler function with the given arguments. + + Args: + args: The arguments to pass to the function. + kwargs: The keyword arguments to pass to the function. + + Returns: + A special code to tell the servlet that the response isn't ready yet + and will come later. + """ + defer.ensureDeferred(render(fn, *args, **kwargs)) + return server.NOT_DONE_YET + + return inner + + +def send_cors(request: Request) -> None: + """Send CORS headers when handling a request.""" + request.setHeader("Access-Control-Allow-Origin", "*") + request.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + request.setHeader("Access-Control-Allow-Headers", "*") + + +def dict_to_json_bytes(content: JsonDict) -> bytes: + """ + Converts a dict into JSON and encodes it to bytes. + + Args: + content: The dict to convert. + + Returns: + The JSON bytes. + """ + return json.dumps(content).encode("UTF-8") From cd80a152af5c6581dff784b730a66d106b840ef2 Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Fri, 3 Dec 2021 16:14:20 +0000 Subject: [PATCH 2/3] Move twisted requirement to install --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 43dd7cd..4122629 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,13 +14,13 @@ packages = python_requires = >= 3.6 install_requires = attrs + twisted [options.extras_require] dev = # for tests tox - twisted aiounittest # for type checking mypy == 0.910 From 2d524edf65f3ec52fd401a63ae0071c8f636572d Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Fri, 3 Dec 2021 16:53:55 +0000 Subject: [PATCH 3/3] Allow untyped calls --- mypy.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mypy.ini b/mypy.ini index be0671c..f0b7a17 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,2 +1,5 @@ [mypy] strict = true +# Allow calling untyped functions in typed contexts. This is needed because we sometimes +# call methods and functions from Twisted, which aren't typed. +allow_untyped_calls = true