diff --git a/duniterpy/api/client.py b/duniterpy/api/client.py index 6085c0371ac0689ec9b90ce66f014792ce641615..e2922b6a0650a9e81e13a669c52fa3d3d7e62522 100644 --- a/duniterpy/api/client.py +++ b/duniterpy/api/client.py @@ -14,15 +14,16 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. """ - import json import logging from typing import Callable, Union, Any, Optional, Dict +from urllib import request, parse -import aiohttp import jsonschema -from aiohttp import ClientResponse, ClientSession, ClientWebSocketResponse -from aiohttp.client import _WSRequestContextManager +from websocket import WebSocket +from aiohttp import ClientSession +from http.client import HTTPResponse + import duniterpy.api.endpoint as endpoint from .errors import DuniterError @@ -32,6 +33,7 @@ logger = logging.getLogger("duniter") RESPONSE_JSON = "json" RESPONSE_TEXT = "text" RESPONSE_AIOHTTP = "aiohttp" +RESPONSE_HTTP = "http" # Connection type constants CONNECTION_TYPE_AIOHTTP = 1 @@ -79,7 +81,7 @@ def parse_error(text: str) -> dict: return data -async def parse_response(response: ClientResponse, schema: dict) -> Any: +async def parse_response(response: str, schema: dict) -> Any: """ Validate and parse the BMA answer @@ -88,52 +90,28 @@ async def parse_response(response: ClientResponse, schema: dict) -> Any: :return: the json data """ try: - data = await response.json() - response.close() + data = json.loads(response) if schema is not None: jsonschema.validate(data, schema) return data - except (TypeError, json.decoder.JSONDecodeError) as e: + except (TypeError, json.decoder.JSONDecodeError) as exception: raise jsonschema.ValidationError( - "Could not parse json : {0}".format(str(e)) - ) from e + "Could not parse json : {0}".format(str(exception)) + ) from exception class WSConnection: """ - From the documentation of the aiohttp_library, the web socket connection - - await ws_connection = session.ws_connect() - - should return a ClientWebSocketResponse object... - - https://docs.aiohttp.org/en/stable/client_quickstart.html#websockets - - In fact, aiohttp.session.ws_connect() returns a aiohttp.client._WSRequestContextManager instance. - It must be used in a with statement to get the ClientWebSocketResponse instance from it (__aenter__). - At the end of the with statement, aiohttp.client._WSRequestContextManager.__aexit__ is called - and close the ClientWebSocketResponse in it. - - await with ws_connection as ws: - await ws.receive_str() + Abstraction layer on websocket library """ - def __init__(self, connection: _WSRequestContextManager) -> None: + def __init__(self, connection: WebSocket) -> None: """ Init WSConnection instance - :param connection: Connection instance of the connection library + :param connection: Connection instance of the websocket library """ - if not isinstance(connection, _WSRequestContextManager): - raise Exception( - BaseException( - "Only aiohttp.client._WSRequestContextManager class supported" - ) - ) - - self.connection_type = CONNECTION_TYPE_AIOHTTP - self._connection = connection # type: _WSRequestContextManager - self.connection = None # type: Optional[ClientWebSocketResponse] + self.connection = connection async def send_str(self, data: str) -> None: """ @@ -145,7 +123,7 @@ class WSConnection: if self.connection is None: raise Exception("Connection property is empty") - await self.connection.send_str(data) + self.connection.send(data) return None async def receive_str(self, timeout: Optional[float] = None) -> str: @@ -157,8 +135,9 @@ class WSConnection: """ if self.connection is None: raise Exception("Connection property is empty") - - return await self.connection.receive_str(timeout=timeout) + if timeout is not None: + self.connection.settimeout(timeout) + return self.connection.recv() async def receive_json(self, timeout: Optional[float] = None) -> Any: """ @@ -169,15 +148,9 @@ class WSConnection: """ if self.connection is None: raise Exception("Connection property is empty") - - return await self.connection.receive_json(timeout=timeout) - - async def init_connection(self): - """ - Mandatory for aiohttp library in order to avoid the usage of the 'with' statement - :return: - """ - self.connection = await self._connection.__aenter__() + if timeout is not None: + self.connection.settimeout(timeout) + return json.loads(self.connection.recv()) async def close(self) -> None: """ @@ -185,8 +158,6 @@ class WSConnection: :return: """ - await self._connection.__aexit__(None, None, None) - if self.connection is None: raise Exception("Connection property is empty") @@ -241,104 +212,90 @@ class API: return url - async def requests_get(self, path: str, **kwargs: Any) -> ClientResponse: + async def request_url( + self, + path: str, + method: str = "GET", + rtype: str = RESPONSE_JSON, + schema: Optional[dict] = None, + json_data: Optional[dict] = None, + bma_errors: bool = False, + **kwargs: Any, + ) -> Any: """ - Requests GET wrapper in order to use API parameters. + Requests wrapper in order to use API parameters. :param path: the request path + :param method: Http method 'GET' or 'POST' (optional, default='GET') + :param rtype: Response type (optional, default RESPONSE_JSON, can be RESPONSE_TEXT, RESPONSE_HTTP) + :param schema: Json Schema to validate response (optional, default None) + :param json_data: Json data as dict (optional, default None) + :param bma_errors: Set it to True to handle Duniter Error Response (optional, default False) + :return: """ logging.debug( "Request : %s", self.reverse_url(self.connection_handler.http_scheme, path) ) url = self.reverse_url(self.connection_handler.http_scheme, path) - response = await self.connection_handler.session.get( - url, - params=kwargs, - headers=self.headers, - proxy=self.connection_handler.proxy, - timeout=15, - ) - if response.status != 200: - try: - error_data = parse_error(await response.text()) - raise DuniterError(error_data) - except (TypeError, jsonschema.ValidationError) as e: - raise ValueError( - "status code != 200 => %d (%s)" - % (response.status, (await response.text())) - ) from e + duniter_request = request.Request(url, method=method) - return response + if kwargs: + # urlencoded http form content as bytes + duniter_request.data = parse.urlencode(kwargs).encode("utf-8") + logging.debug("%s : %s, data=%s", method, url, duniter_request.data) - async def requests_post(self, path: str, **kwargs: Any) -> ClientResponse: - """ - Requests POST wrapper in order to use API parameters. + if json_data: + # json content as bytes + duniter_request.data = json.dumps(json_data).encode("utf-8") + logging.debug("%s : %s, data=%s", method, url, duniter_request.data) - :param path: the request path - :return: - """ - if "self_" in kwargs: - kwargs["self"] = kwargs.pop("self_") + # http header to send json body + self.headers["Content-Type"] = "application/json" - logging.debug("POST : %s", kwargs) - response = await self.connection_handler.session.post( - self.reverse_url(self.connection_handler.http_scheme, path), - data=kwargs, - headers=self.headers, - proxy=self.connection_handler.proxy, - timeout=15, - ) + if self.headers: + duniter_request.headers = self.headers + + if self.connection_handler.proxy: + # proxy host + duniter_request.set_proxy( + self.connection_handler.proxy, self.connection_handler.http_scheme + ) + + response = request.urlopen(duniter_request, timeout=15) # type: HTTPResponse if response.status != 200: - try: - error_data = parse_error(await response.text()) - raise DuniterError(error_data) - except (TypeError, jsonschema.ValidationError) as e: - raise ValueError( - "status code != 200 => %d (%s)" - % (response.status, (await response.text())) - ) from e + content = response.read() + if bma_errors: + try: + error_data = parse_error(content) + raise DuniterError(error_data) + except (TypeError, jsonschema.ValidationError) as exception: + raise ValueError( + "status code != 200 => %d (%s)" % (response.status, content) + ) from exception + + raise ValueError( + "status code != 200 => %d (%s)" % (response.status, content) + ) - return response + # get response content + content = response.read() + response.close() - async def requests( - self, - method: str = "GET", - path: str = "", - data: Optional[dict] = None, - _json: Optional[dict] = None, - ) -> ClientResponse: - """ - Generic requests wrapper on aiohttp + # if schema supplied... + if schema is not None: + # validate response + await parse_response(content, schema) - :param method: the request http method - :param path: the path added to endpoint - :param data: data for form POST request - :param _json: json for json POST request - :rtype: aiohttp.ClientResponse - """ - url = self.reverse_url(self.connection_handler.http_scheme, path) + # return the chosen type + result = response # type: Any + if rtype == RESPONSE_TEXT: + result = content + elif rtype == RESPONSE_JSON: + result = json.loads(content) - if data is not None: - logging.debug("%s : %s, data=%s", method, url, data) - elif _json is not None: - logging.debug("%s : %s, json=%s", method, url, _json) - # http header to send json body - self.headers["Content-Type"] = "application/json" - else: - logging.debug("%s : %s", method, url) - - response = await self.connection_handler.session.request( - method, - url, - data=data, - json=_json, - headers=self.headers, - proxy=self.connection_handler.proxy, - timeout=15, - ) - return response + return result async def connect_ws(self, path: str) -> WSConnection: """ @@ -354,16 +311,20 @@ class API: """ url = self.reverse_url(self.connection_handler.ws_scheme, path) - connection = WSConnection( - self.connection_handler.session.ws_connect( - url, proxy=self.connection_handler.proxy, autoclose=False - ) - ) - - # init aiohttp connection - await connection.init_connection() + ws = WebSocket() + if self.connection_handler.proxy: + proxy_split = ":".split(self.connection_handler.proxy) + if len(proxy_split) == 2: + host = proxy_split[0] + port = proxy_split[1] + else: + host = self.connection_handler.proxy + port = 80 + ws.connect(url, http_proxy_host=host, http_proxy_port=port) + else: + ws.connect(url) - return connection + return WSConnection(ws) class Client: @@ -415,7 +376,7 @@ class Client: :param url_path: Url encoded path following the endpoint :param params: Url query string parameters dictionary (optional, default None) - :param rtype: Response type (optional, default RESPONSE_JSON) + :param rtype: Response type (optional, default RESPONSE_JSON, can be RESPONSE_TEXT, RESPONSE_HTTP) :param schema: Json Schema to validate response (optional, default None) :return: """ @@ -424,22 +385,10 @@ class Client: client = API(self.endpoint.conn_handler(self.session, self.proxy)) - # get aiohttp response - response = await client.requests_get(url_path, **params) - - # if schema supplied... - if schema is not None: - # validate response - await parse_response(response, schema) - - # return the chosen type - result = response # type: Any - if rtype == RESPONSE_TEXT: - result = await response.text() - elif rtype == RESPONSE_JSON: - result = await response.json() - - return result + # get response + return await client.request_url( + url_path, "GET", rtype, schema, bma_errors=True, **params + ) async def post( self, @@ -453,7 +402,7 @@ class Client: :param url_path: Url encoded path following the endpoint :param params: Url query string parameters dictionary (optional, default None) - :param rtype: Response type (optional, default RESPONSE_JSON) + :param rtype: Response type (optional, default RESPONSE_JSON, can be RESPONSE_TEXT, RESPONSE_HTTP) :param schema: Json Schema to validate response (optional, default None) :return: """ @@ -462,28 +411,15 @@ class Client: client = API(self.endpoint.conn_handler(self.session, self.proxy)) - # get aiohttp response - response = await client.requests_post(url_path, **params) - - # if schema supplied... - if schema is not None: - # validate response - await parse_response(response, schema) - - # return the chosen type - result = response # type: Any - if rtype == RESPONSE_TEXT: - result = await response.text() - elif rtype == RESPONSE_JSON: - result = await response.json() - - return result + # get response + return await client.request_url( + url_path, "POST", rtype, schema, bma_errors=True, **params + ) async def query( self, query: str, variables: Optional[dict] = None, - rtype: str = RESPONSE_JSON, schema: Optional[dict] = None, ) -> Any: """ @@ -503,24 +439,16 @@ class Client: client = API(self.endpoint.conn_handler(self.session, self.proxy)) # get aiohttp response - response = await client.requests("POST", _json=payload) + response = await client.request_url( + "", "POST", rtype=RESPONSE_JSON, schema=schema, json_data=payload + ) # if schema supplied... if schema is not None: # validate response await parse_response(response, schema) - # return the chosen type - result = response # type: Any - if rtype == RESPONSE_TEXT or response.status > 399: - result = await response.text() - elif rtype == RESPONSE_JSON: - try: - result = await response.json() - except aiohttp.client_exceptions.ContentTypeError as exception: - logging.error("Response is not a json format: %s", exception) - # return response to debug... - return result + return response async def connect_ws(self, path: str = "") -> WSConnection: """ diff --git a/examples/request_data.py b/examples/request_data.py index 3b9473f148cbde67ef053cc3abe7e9a35eb4053f..05e3fd948573c2c567a7006d92cb1d83436d1714 100644 --- a/examples/request_data.py +++ b/examples/request_data.py @@ -16,7 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. """ import asyncio -from duniterpy.api.client import Client, RESPONSE_AIOHTTP +from duniterpy.api.client import Client, RESPONSE_HTTP from duniterpy.api import bma # CONFIG ####################################### @@ -77,7 +77,7 @@ async def main(): # Get the node summary infos (direct REST GET request) print("\nCall direct get on node/summary") response = await client.get( - "node/summary", rtype=RESPONSE_AIOHTTP, schema=summary_schema + "node/summary", rtype=RESPONSE_HTTP, schema=summary_schema ) print(response) diff --git a/examples/request_data_graphql.py b/examples/request_data_graphql.py index 0253990a1a7483404bda0a7fcbe8bb0de4f82b66..9c3946d3ac5c10c4b147d5e151f9cdc6e40e7c00 100644 --- a/examples/request_data_graphql.py +++ b/examples/request_data_graphql.py @@ -1,4 +1,3 @@ -import asyncio import sys import json diff --git a/pyproject.toml b/pyproject.toml index 7c5894e75c2c3bd2f0ee0d81e547ca2b6dd6dd91..63dceff656b195bf58313340e2e586d4151a0458 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ base58 = "^2.0.0" libnacl = "^1.7.2" pyaes = "^1.6.1" graphql-core = "^3.1.2" +websocket-client = "^0.57" [tool.poetry.dev-dependencies] black = "^20.8b1"