From 13c89fc5ef7ec057b42feac0e117b45855c9b1ff Mon Sep 17 00:00:00 2001 From: Carlosgg Date: Tue, 5 Sep 2023 03:56:54 +0100 Subject: [PATCH 1/2] micropython/uaiohttpclient: Add ssl support. Add ssl support and SSLContext support with ClientSession Signed-off-by: Carlos Gil --- .../uaiohttpclient/clientsession_example.py | 17 +++ micropython/uaiohttpclient/uaiohttpclient.py | 132 +++++++++++++++++- 2 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 micropython/uaiohttpclient/clientsession_example.py diff --git a/micropython/uaiohttpclient/clientsession_example.py b/micropython/uaiohttpclient/clientsession_example.py new file mode 100644 index 000000000..31e037a8e --- /dev/null +++ b/micropython/uaiohttpclient/clientsession_example.py @@ -0,0 +1,17 @@ +import uaiohttpclient as aiohttp +import asyncio + + +async def fetch(client): + async with client.get("http://micropython.org") as resp: + assert resp.status == 200 + return await resp.text() + + +async def main(): + async with aiohttp.ClientSession() as client: + html = await fetch(client) + print(html) + + +asyncio.run(main()) diff --git a/micropython/uaiohttpclient/uaiohttpclient.py b/micropython/uaiohttpclient/uaiohttpclient.py index 25b2e62a9..377109780 100644 --- a/micropython/uaiohttpclient/uaiohttpclient.py +++ b/micropython/uaiohttpclient/uaiohttpclient.py @@ -8,6 +8,9 @@ def __init__(self, reader): def read(self, sz=-1): return (yield from self.content.read(sz)) + def text(self, sz=-1): + return self.read(sz=sz) + def __repr__(self): return "" % (self.status, self.headers) @@ -40,22 +43,139 @@ def __repr__(self): return "" % (self.status, self.headers) +class _RequestContextManager: + def __init__(self, client, request_co): + self.reqco = request_co + self.client = client + + async def __aenter__(self): + return await self.reqco + + async def __aexit__(self, *args): + await self.client._reader.aclose() + return await asyncio.sleep(0) + + +class ClientSession: + def __init__(self): + self._reader = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return await asyncio.sleep(0) + + def request(self, method, url, ssl=None): + return _RequestContextManager(self, self._request(method, url, ssl=ssl)) + + async def _request(self, method, url, ssl=None): + redir_cnt = 0 + redir_url = None + while redir_cnt < 2: + reader = yield from self.request_raw(method, url, ssl) + headers = [] + sline = yield from reader.readline() + sline = sline.split(None, 2) + status = int(sline[1]) + chunked = False + while True: + line = yield from reader.readline() + if not line or line == b"\r\n": + break + headers.append(line) + if line.startswith(b"Transfer-Encoding:"): + if b"chunked" in line: + chunked = True + elif line.startswith(b"Location:"): + url = line.rstrip().split(None, 1)[1].decode("latin-1") + + if 301 <= status <= 303: + redir_cnt += 1 + yield from reader.aclose() + continue + break + + if chunked: + resp = ChunkedClientResponse(reader) + else: + resp = ClientResponse(reader) + resp.status = status + resp.headers = headers + self._reader = reader + return resp + + async def request_raw(self, method, url, ssl=None): + try: + proto, dummy, host, path = url.split("/", 3) + except ValueError: + proto, dummy, host = url.split("/", 2) + path = "" + + if proto == "http:": + port = 80 + elif proto == "https:": + port = 443 + if ssl is None: + ssl = True + else: + raise ValueError("Unsupported protocol: " + proto) + + if ":" in host: + host, port = host.split(":", 1) + port = int(port) + + reader, writer = yield from asyncio.open_connection(host, port, ssl=ssl) + # Use protocol 1.0, because 1.1 always allows to use chunked transfer-encoding + # But explicitly set Connection: close, even though this should be default for 1.0, + # because some servers misbehave w/o it. + query = ( + "%s /%s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\nUser-Agent: compat\r\n\r\n" + % ( + method, + path, + host, + ) + ) + yield from writer.awrite(query.encode("latin-1")) + # yield from writer.aclose() + return reader + + def get(self, url, ssl=None): + return _RequestContextManager(self, self._request("GET", url, ssl=ssl)) + + def request_raw(method, url): try: proto, dummy, host, path = url.split("/", 3) except ValueError: proto, dummy, host = url.split("/", 2) path = "" - if proto != "http:": + + if proto == "http:": + port = 80 + elif proto == "https:": + port = 443 + else: raise ValueError("Unsupported protocol: " + proto) - reader, writer = yield from asyncio.open_connection(host, 80) + + if ":" in host: + host, port = host.split(":", 1) + port = int(port) + + reader, writer = yield from asyncio.open_connection( + host, port, ssl=proto == "https:" + ) # Use protocol 1.0, because 1.1 always allows to use chunked transfer-encoding # But explicitly set Connection: close, even though this should be default for 1.0, # because some servers misbehave w/o it. - query = "%s /%s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\nUser-Agent: compat\r\n\r\n" % ( - method, - path, - host, + query = ( + "%s /%s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\nUser-Agent: compat\r\n\r\n" + % ( + method, + path, + host, + ) ) yield from writer.awrite(query.encode("latin-1")) # yield from writer.aclose() From 727030b76ca160c199e2e3de4d6a8429b22900b3 Mon Sep 17 00:00:00 2001 From: Carlosgg Date: Thu, 19 Oct 2023 02:49:49 +0100 Subject: [PATCH 2/2] Add aiohttp.ClientSession with methods. - `base_url` parameter: to simplify reusing the session with different methods. - `json` parameter. --- .../uaiohttpclient/clientsession_example.py | 5 +- .../clientsession_methods_example.py | 22 ++++ micropython/uaiohttpclient/uaiohttpclient.py | 116 +++++++++++++----- 3 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 micropython/uaiohttpclient/clientsession_methods_example.py diff --git a/micropython/uaiohttpclient/clientsession_example.py b/micropython/uaiohttpclient/clientsession_example.py index 31e037a8e..d03cf0b8e 100644 --- a/micropython/uaiohttpclient/clientsession_example.py +++ b/micropython/uaiohttpclient/clientsession_example.py @@ -11,7 +11,8 @@ async def fetch(client): async def main(): async with aiohttp.ClientSession() as client: html = await fetch(client) - print(html) + print(html.decode()) -asyncio.run(main()) +if __name__ == "__main__": + asyncio.run(main()) diff --git a/micropython/uaiohttpclient/clientsession_methods_example.py b/micropython/uaiohttpclient/clientsession_methods_example.py new file mode 100644 index 000000000..3ea83d731 --- /dev/null +++ b/micropython/uaiohttpclient/clientsession_methods_example.py @@ -0,0 +1,22 @@ +import uaiohttpclient as aiohttp +import asyncio + + +async def main(): + async with aiohttp.ClientSession("http://httpbin.org") as session: + async with session.get("/get") as resp: + assert resp.status == 200 + rget = await resp.text() + print(f"GET: {rget.decode()}") + async with session.post("/post", json={"foo": "bar"}) as resp: + assert resp.status == 200 + rpost = await resp.text() + print(f"POST: {rpost.decode()}") + async with session.put("/put", data=b"data") as resp: + assert resp.status == 200 + rput = await resp.json() + print("PUT: ", rput) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/micropython/uaiohttpclient/uaiohttpclient.py b/micropython/uaiohttpclient/uaiohttpclient.py index 377109780..d1afb2b21 100644 --- a/micropython/uaiohttpclient/uaiohttpclient.py +++ b/micropython/uaiohttpclient/uaiohttpclient.py @@ -1,4 +1,5 @@ import uasyncio as asyncio +import json as _json class ClientResponse: @@ -11,6 +12,9 @@ def read(self, sz=-1): def text(self, sz=-1): return self.read(sz=sz) + async def json(self): + return _json.loads(await self.read()) + def __repr__(self): return "" % (self.status, self.headers) @@ -57,8 +61,9 @@ async def __aexit__(self, *args): class ClientSession: - def __init__(self): + def __init__(self, base_url=""): self._reader = None + self._base_url = base_url async def __aenter__(self): return self @@ -66,21 +71,24 @@ async def __aenter__(self): async def __aexit__(self, *args): return await asyncio.sleep(0) - def request(self, method, url, ssl=None): - return _RequestContextManager(self, self._request(method, url, ssl=ssl)) + def request(self, method, url, data=None, json=None, ssl=None): + return _RequestContextManager( + self, + self._request(method, self._base_url + url, data=data, json=json, ssl=ssl), + ) - async def _request(self, method, url, ssl=None): + async def _request(self, method, url, data=None, json=None, ssl=None): redir_cnt = 0 redir_url = None while redir_cnt < 2: - reader = yield from self.request_raw(method, url, ssl) + reader = await self.request_raw(method, url, data, json, ssl) headers = [] - sline = yield from reader.readline() + sline = await reader.readline() sline = sline.split(None, 2) status = int(sline[1]) chunked = False while True: - line = yield from reader.readline() + line = await reader.readline() if not line or line == b"\r\n": break headers.append(line) @@ -92,7 +100,7 @@ async def _request(self, method, url, ssl=None): if 301 <= status <= 303: redir_cnt += 1 - yield from reader.aclose() + await reader.aclose() continue break @@ -105,7 +113,11 @@ async def _request(self, method, url, ssl=None): self._reader = reader return resp - async def request_raw(self, method, url, ssl=None): + async def request_raw(self, method, url, data=None, json=None, ssl=None): + if json and isinstance(json, dict): + data = _json.dumps(json) + if data is not None and method == "GET": + method = "POST" try: proto, dummy, host, path = url.split("/", 3) except ValueError: @@ -125,24 +137,75 @@ async def request_raw(self, method, url, ssl=None): host, port = host.split(":", 1) port = int(port) - reader, writer = yield from asyncio.open_connection(host, port, ssl=ssl) + reader, writer = await asyncio.open_connection(host, port, ssl=ssl) + # Use protocol 1.0, because 1.1 always allows to use chunked transfer-encoding # But explicitly set Connection: close, even though this should be default for 1.0, # because some servers misbehave w/o it. - query = ( - "%s /%s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\nUser-Agent: compat\r\n\r\n" - % ( - method, - path, - host, + if not data: + query = ( + "%s /%s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\nUser-Agent: compat\r\n\r\n" + % ( + method, + path, + host, + ) ) - ) - yield from writer.awrite(query.encode("latin-1")) + else: + query = ( + """%s /%s HTTP/1.0\r\nHost: %s\r\n%sContent-Length: %s\r\n\r\n%s\r\nConnection: close\r\nUser-Agent: compat\r\n\r\n""" + % ( + method, + path, + host, + "Content-Type: application/json\r\n" if json else "", + str(len(str(data))), + data, + ) + ) + + await writer.awrite(query.encode("latin-1")) # yield from writer.aclose() return reader def get(self, url, ssl=None): - return _RequestContextManager(self, self._request("GET", url, ssl=ssl)) + return _RequestContextManager(self, self._request("GET", self._base_url + url, ssl=ssl)) + + def post(self, url, data=None, json=None, ssl=None): + return _RequestContextManager( + self, + self._request("POST", self._base_url + url, data=data, json=json, ssl=ssl), + ) + + def put(self, url, data=None, json=None, ssl=None): + return _RequestContextManager( + self, + self._request("PUT", self._base_url + url, data=data, json=json, ssl=ssl), + ) + + def patch(self, url, data=None, json=None, ssl=None): + return _RequestContextManager( + self, + self._request("PATCH", self._base_url + url, data=data, json=json, ssl=ssl), + ) + + def delete(self, url, ssl=None): + return _RequestContextManager( + self, + self._request("DELETE", self._base_url + url, ssl=ssl), + ) + + def head(self, url, ssl=None): + return _RequestContextManager( + self, + self._request("HEAD", self._base_url + url, ssl=ssl), + ) + + def options(self, url, ssl=None): + return _RequestContextManager( + self, + self._request("OPTIONS", self._base_url + url, ssl=ssl), + ) def request_raw(method, url): @@ -163,19 +226,14 @@ def request_raw(method, url): host, port = host.split(":", 1) port = int(port) - reader, writer = yield from asyncio.open_connection( - host, port, ssl=proto == "https:" - ) + reader, writer = yield from asyncio.open_connection(host, port, ssl=proto == "https:") # Use protocol 1.0, because 1.1 always allows to use chunked transfer-encoding # But explicitly set Connection: close, even though this should be default for 1.0, # because some servers misbehave w/o it. - query = ( - "%s /%s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\nUser-Agent: compat\r\n\r\n" - % ( - method, - path, - host, - ) + query = "%s /%s HTTP/1.0\r\nHost: %s\r\nConnection: close\r\nUser-Agent: compat\r\n\r\n" % ( + method, + path, + host, ) yield from writer.awrite(query.encode("latin-1")) # yield from writer.aclose()