diff --git a/matrix_client/api.py b/matrix_client/api.py index 46682a2a..4f3d2857 100644 --- a/matrix_client/api.py +++ b/matrix_client/api.py @@ -26,19 +26,14 @@ MATRIX_V2_API_PATH = "/_matrix/client/r0" -class MatrixHttpApi(object): - """Contains all raw Matrix HTTP Client-Server API calls. +class MatrixApi(object): + """Contains transport-agnostic Matrix Client-Server API calls. - Usage: - matrix = MatrixHttpApi("https://matrix.org", token="foobar") - response = matrix.sync() - response = matrix.send_message("!roomid:matrix.org", "Hello!") - - For room and sync handling, consider using MatrixClient. + For usage, MatrixApi must be subclassed with a valid _send method. """ - def __init__(self, base_url, token=None, identity=None): - """Construct and configure the HTTP API. + def __init__(self, base_url=None, token=None, identity=None): + """Construct and configure the API. Args: base_url(str): The home server URL e.g. 'http://localhost:8008' @@ -524,45 +519,8 @@ def create_filter(self, user_id, filter_params): filter_params, api_path=MATRIX_V2_API_PATH) - def _send(self, method, path, content=None, query_params={}, headers={}, - api_path="/_matrix/client/api/v1"): - method = method.upper() - if method not in ["GET", "PUT", "DELETE", "POST"]: - raise MatrixError("Unsupported HTTP method: %s" % method) - - if "Content-Type" not in headers: - headers["Content-Type"] = "application/json" - - query_params["access_token"] = self.token - if self.identity: - query_params["user_id"] = self.identity - - endpoint = self.base_url + api_path + path - - if headers["Content-Type"] == "application/json" and content is not None: - content = json.dumps(content) - - response = None - while True: - response = requests.request( - method, endpoint, - params=query_params, - data=content, - headers=headers, - verify=self.validate_cert - ) - - if response.status_code == 429: - sleep(response.json()['retry_after_ms'] / 1000) - else: - break - - if response.status_code < 200 or response.status_code >= 300: - raise MatrixRequestError( - code=response.status_code, content=response.text - ) - - return response.json() + def _send(self, *args, **kwargs): + raise NotImplementedError("MatrixApi must be subclassed by a transport class.") def media_upload(self, content, content_type): return self._send( @@ -641,3 +599,55 @@ def get_room_members(self, room_id): """ return self._send("GET", "/rooms/{}/members".format(quote(room_id)), api_path=MATRIX_V2_API_PATH) + + +class MatrixHttpApi(MatrixApi): + """Contains all Matrix Client-Server API calls with Http transport. + + Usage: + matrix = MatrixHttpApi("https://matrix.org", token="foobar") + response = matrix.sync() + response = matrix.send_message("!roomid:matrix.org", "Hello!") + + For room and sync handling, consider using MatrixClient. + """ + + def _send(self, method, path, content=None, query_params={}, headers={}, + api_path="/_matrix/client/api/v1"): + method = method.upper() + if method not in ["GET", "PUT", "DELETE", "POST"]: + raise MatrixError("Unsupported HTTP method: %s" % method) + + if "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + + query_params["access_token"] = self.token + if self.identity: + query_params["user_id"] = self.identity + + endpoint = self.base_url + api_path + path + + if headers["Content-Type"] == "application/json" and content is not None: + content = json.dumps(content) + + response = None + while True: + response = requests.request( + method, endpoint, + params=query_params, + data=content, + headers=headers, + verify=self.validate_cert + ) + + if response.status_code == 429: + sleep(response.json()['retry_after_ms'] / 1000) + else: + break + + if response.status_code < 200 or response.status_code >= 300: + raise MatrixRequestError( + code=response.status_code, content=response.text + ) + + return response.json() diff --git a/matrix_client/client.py b/matrix_client/client.py index 0a11cf79..0e7876cc 100644 --- a/matrix_client/client.py +++ b/matrix_client/client.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright 2015 OpenMarket Ltd +# Copyright 2015, 2017 OpenMarket Ltd, Adam Beckmeyer # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ from .errors import MatrixRequestError, MatrixUnexpectedResponse from .room import Room from .user import User +from .utils import AggregateApi from threading import Thread from time import sleep from uuid import uuid4 @@ -45,6 +46,11 @@ class MatrixClient(object): room = client.join_room("#matrix:matrix.org") response = room.send_text("Hello!") response = room.kick("@bob:matrix.org") + Usage (multiple api): + # This will create a client that e.g. has device and backup device associated + apis = (MatrixHttpApi(token="foo"), MatrixHttpApi(token="bar")) + client = MatrixClient("https://matrix.org", token="foobar", + user_id="@foobar:matrix.org", apis=apis) Incoming event callbacks (scopes): @@ -59,7 +65,8 @@ def global_callback(incoming_event): """ - def __init__(self, base_url, token=None, user_id=None, valid_cert_check=True): + def __init__(self, base_url, token=None, user_id=None, + valid_cert_check=True, apis=()): """ Create a new Matrix Client object. Args: @@ -71,7 +78,9 @@ def __init__(self, base_url, token=None, user_id=None, valid_cert_check=True): (as obtained when initially logging in to obtain the token) if supplying a token; otherwise, ignored. valid_cert_check (bool): Check the homeservers - certificate on connections? + certificate on connections (if not specifying apis)? + apis (Optional[MatrixApi]): Iterable of MatrixApi objects + instantiated with any necessary options Returns: MatrixClient @@ -82,8 +91,16 @@ def __init__(self, base_url, token=None, user_id=None, valid_cert_check=True): if token is not None and user_id is None: raise ValueError("must supply user_id along with token") - self.api = MatrixHttpApi(base_url, token) - self.api.validate_certificate(valid_cert_check) + # In cases where apis is not specified, assume single Http Api (legacy behavior) + if not apis: + self.api = MatrixHttpApi(base_url, token=token) + self.api.validate_certificate(valid_cert_check) + else: + for api_object in apis: + api_object.base_url = base_url + api_object.token = token + self.api = AggregateApi(apis) + self.listeners = [] self.invite_listeners = [] self.left_listeners = [] diff --git a/matrix_client/errors.py b/matrix_client/errors.py index 10cd039f..c7b31b5c 100644 --- a/matrix_client/errors.py +++ b/matrix_client/errors.py @@ -5,6 +5,7 @@ class MatrixError(Exception): class MatrixUnexpectedResponse(MatrixError): """The home server gave an unexpected response. """ + def __init__(self, content=""): super(MatrixError, self).__init__(content) self.content = content @@ -17,3 +18,13 @@ def __init__(self, code=0, content=""): super(MatrixRequestError, self).__init__("%d: %s" % (code, content)) self.code = code self.content = content + + +class MatrixApiError(MatrixError): + """An Api method was unable to be completed successfully.""" + + def __init__(self, content="", api_method="", raised_errors=""): + self.content = content + super(MatrixError, self).__init__( + "{}: {}\nErrors raised: {}".format(content, api_method, raised_errors) + ) diff --git a/matrix_client/utils.py b/matrix_client/utils.py new file mode 100644 index 00000000..6a3f3c10 --- /dev/null +++ b/matrix_client/utils.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Adam Beckmeyer +# +# 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 . import errors +from .api import MatrixApi +import functools + + +class AggregateApi: + """Groups together multiple Api objects to call in order.""" + + def __init__(self, api_list): + """Constructs the aggregate api to look like MatrixApi. + + Args: + api_list(iterable): Iterable of api objects to be tried in order. + """ + self.apis = api_list + # Make methods of AggregateApi look like MatrixApi + method_names = (m for m in dir(MatrixApi) if not m.startswith("_")) + for m in method_names: + setattr(self, m, functools.partial(self._call_api_methods, m)) + # Logging in and logging out should occur for all apis + setattr(self, "login", functools.partial(self._call_all_api_methods, "login")) + setattr(self, "logout", functools.partial(self._call_all_api_methods, "logout")) + + def _call_api_methods(self, method_name, *args, **kwargs): + """Calls method of each listed api until successful. + + Args: + method_name(str): Method to be called on each object in self.apis + args: To be passed when calling method. + kwargs: To be passed when calling method. + """ + api_methods = (getattr(api, method_name) for api in self.apis) + raised_errors = [] + for method in api_methods: + try: + return method(*args, **kwargs) + except NotImplementedError as e: + raised_errors.append(e) + + raise errors.MatrixApiError('Unable to complete api method', method_name, raised_errors) + + def _call_all_api_methods(self, method_name, *args, **kwargs): + """Calls method for all apis in self.apis. + + Args: + method_name(str): Method to be called on each object in self.apis + args: To be passed when calling method. + kwargs: To be passed when calling method. + """ + api_methods = (getattr(api, method_name) for api in self.apis) + return_values = [] + raised_errors = [] + for method in api_methods: + try: + return_values.append(method(*args, **kwargs)) + except NotImplementedError as e: + raised_errors.append(e) + + # Check that method has completed for all apis + if len(return_values) == len(self.apis): + return return_values + else: + raise errors.MatrixApiError( + 'Not able to complete method for all apis', method_name, raised_errors + ) diff --git a/test/client_test.py b/test/client_test.py index a2e6f9b9..5a3f6a1d 100644 --- a/test/client_test.py +++ b/test/client_test.py @@ -1,4 +1,6 @@ from matrix_client.client import MatrixClient, Room, User +from matrix_client.api import MatrixApi +from matrix_client import errors import pytest @@ -137,3 +139,10 @@ def dummy_listener(): break assert not found_listener, "listener was not removed properly" + + +def test_aggregate_api(): + api = MatrixApi() + with pytest.raises(errors.MatrixApiError): + client = MatrixClient("http://example.com", token="foo", + user_id="@bar:example.com", apis=(api,))