Skip to content

Move main api methods into transport-agnostic class #128

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 59 additions & 49 deletions matrix_client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
27 changes: 22 additions & 5 deletions matrix_client/client.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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):

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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 = []
Expand Down
11 changes: 11 additions & 0 deletions matrix_client/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
)
79 changes: 79 additions & 0 deletions matrix_client/utils.py
Original file line number Diff line number Diff line change
@@ -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
)
9 changes: 9 additions & 0 deletions test/client_test.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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,))