diff --git a/README.md b/README.md index 31222c8..a0fbd5b 100755 --- a/README.md +++ b/README.md @@ -113,6 +113,15 @@ It will return the appropriate response for a LNURL. LnurlPayResponse(tag='payRequest', callback=WebUrl('https://lnurl.bigsun.xyz/lnurl-pay/callback/2169831', scheme='https', host='lnurl.bigsun.xyz', tld='xyz', host_type='domain', path='/lnurl-pay/callback/2169831'), minSendable=10000, maxSendable=10000, metadata=LnurlPayMetadata('[["text/plain","NgHaEyaZNDnW iI DsFYdkI"],["image/png;base64","iVBOR...uQmCC"]]')) ``` +All network helpers accept an optional `httpx.AsyncClient` through the `client` argument. The caller owns a provided +client and the library does not close it. The `user_agent`, `timeout`, and `tor_socks` arguments configure only clients +created by the library. + +```python +async with httpx.AsyncClient(follow_redirects=False) as client: + response = await lnurl.handle(value, client=client) +``` + You can execute and LNURL with either payRequest, withdrawRequest, addressRequest or login tag using the `execute` function. ```python >>> import lnurl diff --git a/lnurl/core.py b/lnurl/core.py index 857c647..36bc258 100644 --- a/lnurl/core.py +++ b/lnurl/core.py @@ -1,3 +1,5 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from json import JSONDecodeError from typing import Any, Optional @@ -31,6 +33,28 @@ TIMEOUT = 5 +@asynccontextmanager +async def _http_client( + client: Optional[httpx.AsyncClient], + *, + user_agent: Optional[str], + proxy: Optional[str], + timeout: Optional[int], +) -> AsyncIterator[httpx.AsyncClient]: + if client is not None: + yield client + return + + headers = {"User-Agent": user_agent or USER_AGENT} + async with httpx.AsyncClient( + headers=headers, + follow_redirects=True, + proxy=proxy, + timeout=timeout or TIMEOUT, + ) as default_client: + yield default_client + + def decode(lnurl: str) -> Lnurl: try: return Lnurl(lnurl) @@ -52,12 +76,12 @@ async def get( user_agent: Optional[str] = None, timeout: Optional[int] = None, tor_socks: Optional[str] = None, + client: Optional[httpx.AsyncClient] = None, ) -> LnurlResponseModel: - headers = {"User-Agent": user_agent or USER_AGENT} proxy = tor_socks or TOR_SOCKS if ".onion" in url else None - async with httpx.AsyncClient(headers=headers, follow_redirects=True, proxy=proxy) as client: + async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client: try: - res = await client.get(url, timeout=timeout or TIMEOUT) + res = await http_client.get(url) res.raise_for_status() except httpx.ConnectError as exc: if proxy: @@ -87,11 +111,19 @@ async def handle( user_agent: Optional[str] = None, timeout: Optional[int] = None, tor_socks: Optional[str] = None, + *, + client: Optional[httpx.AsyncClient] = None, ) -> LnurlResponseModel: try: if "@" in lnurl: lnaddress = LnAddress(lnurl) - return await get(lnaddress.url, response_class=response_class, user_agent=user_agent, timeout=timeout) + return await get( + lnaddress.url, + response_class=response_class, + user_agent=user_agent, + timeout=timeout, + client=client, + ) lnurl = Lnurl(lnurl) except (ValidationError, ValueError): raise InvalidLnurl @@ -101,7 +133,12 @@ async def handle( return LnurlAuthResponse(callback=callback_url, k1=lnurl.url.query_params["k1"]) return await get( - lnurl.url, response_class=response_class, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks + lnurl.url, + response_class=response_class, + user_agent=user_agent, + timeout=timeout, + tor_socks=tor_socks, + client=client, ) @@ -111,20 +148,56 @@ async def execute( user_agent: Optional[str] = None, timeout: Optional[int] = None, tor_socks: Optional[str] = None, + *, + client: Optional[httpx.AsyncClient] = None, ) -> LnurlResponseModel: try: - res = await handle(bech32_or_address, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) + res = await handle( + bech32_or_address, + user_agent=user_agent, + timeout=timeout, + tor_socks=tor_socks, + client=client, + ) except Exception as exc: raise LnurlResponseException(str(exc)) if isinstance(res, LnurlPayResponse) and res.tag == "payRequest": - return await execute_pay_request(res, int(value), user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) + return await execute_pay_request( + res, + int(value), + user_agent=user_agent, + timeout=timeout, + tor_socks=tor_socks, + client=client, + ) elif isinstance(res, LnurlAuthResponse) and res.tag == "login": - return await execute_login(res, value, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) + return await execute_login( + res, + value, + user_agent=user_agent, + timeout=timeout, + tor_socks=tor_socks, + client=client, + ) elif isinstance(res, LnurlWithdrawResponse) and res.tag == "withdrawRequest": - return await execute_withdraw(res, value, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) + return await execute_withdraw( + res, + value, + user_agent=user_agent, + timeout=timeout, + tor_socks=tor_socks, + client=client, + ) elif isinstance(res, LnurlAddressRequestResponse) and res.tag == "addressRequest": - return await execute_address_request(res, value, user_agent=user_agent, timeout=timeout, tor_socks=tor_socks) + return await execute_address_request( + res, + value, + user_agent=user_agent, + timeout=timeout, + tor_socks=tor_socks, + client=client, + ) raise LnurlResponseException("tag not implemented") @@ -136,6 +209,8 @@ async def execute_pay_request( user_agent: Optional[str] = None, timeout: Optional[int] = None, tor_socks: Optional[str] = None, + *, + client: Optional[httpx.AsyncClient] = None, ) -> LnurlPayActionResponse: if not res.minSendable <= MilliSatoshi(msat) <= res.maxSendable: raise LnurlResponseException(f"Amount {msat} not in range {res.minSendable} - {res.maxSendable}") @@ -148,14 +223,12 @@ async def execute_pay_request( params["comment"] = comment try: - headers = {"User-Agent": user_agent or USER_AGENT} proxy = tor_socks or TOR_SOCKS if res.callback.host and res.callback.host.endswith(".onion") else None - async with httpx.AsyncClient(headers=headers, follow_redirects=True, proxy=proxy) as client: + async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client: try: - res2 = await client.get( + res2 = await http_client.get( url=str(res.callback), params=params, - timeout=timeout or TIMEOUT, ) res2.raise_for_status() except httpx.ConnectError as exc: @@ -190,6 +263,8 @@ async def execute_login( user_agent: Optional[str] = None, timeout: Optional[int] = None, tor_socks: Optional[str] = None, + *, + client: Optional[httpx.AsyncClient] = None, ) -> LnurlResponseModel: if not res.callback: raise LnurlResponseException("LNURLauth callback does not exist") @@ -203,17 +278,15 @@ async def execute_login( else: raise LnurlResponseException("Seed or signed_message is required for LNURLauth") key, sig = lnurlauth_signature(res.k1, linking_key=linking_key) - headers = {"User-Agent": user_agent or USER_AGENT} proxy = tor_socks or TOR_SOCKS if res.callback.host and res.callback.host.endswith(".onion") else None - async with httpx.AsyncClient(headers=headers, follow_redirects=True, proxy=proxy) as client: + async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client: try: - res2 = await client.get( + res2 = await http_client.get( url=res.callback, params={ "key": key, "sig": sig, }, - timeout=timeout or TIMEOUT, ) res2.raise_for_status() except httpx.ConnectError as exc: @@ -234,6 +307,8 @@ async def execute_withdraw( user_agent: Optional[str] = None, timeout: Optional[int] = None, tor_socks: Optional[str] = None, + *, + client: Optional[httpx.AsyncClient] = None, ) -> LnurlSuccessResponse: try: invoice = bolt11_decode(pr) @@ -243,17 +318,15 @@ async def execute_withdraw( amount = invoice.amount_msat or res.minWithdrawable if not res.minWithdrawable <= MilliSatoshi(amount) <= res.maxWithdrawable: raise LnurlResponseException(f"Amount {amount} not in range {res.minWithdrawable} - {res.maxWithdrawable}") - headers = {"User-Agent": user_agent or USER_AGENT} proxy = tor_socks or TOR_SOCKS if res.callback.host and res.callback.host.endswith(".onion") else None - async with httpx.AsyncClient(headers=headers, follow_redirects=True, proxy=proxy) as client: + async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client: try: - res2 = await client.get( + res2 = await http_client.get( url=res.callback, params={ "k1": res.k1, "pr": pr, }, - timeout=timeout or TIMEOUT, ) res2.raise_for_status() except httpx.ConnectError as exc: @@ -279,23 +352,23 @@ async def execute_address_request( user_agent: Optional[str] = None, timeout: Optional[int] = None, tor_socks: Optional[str] = None, + *, + client: Optional[httpx.AsyncClient] = None, ) -> LnurlResponseModel: try: lnaddress = LnAddress(address) except (ValidationError, ValueError, LnAddressError) as exc: raise LnurlResponseException("Invalid Lightning address.") from exc - headers = {"User-Agent": user_agent or USER_AGENT} proxy = tor_socks or TOR_SOCKS if res.callback.host and res.callback.host.endswith(".onion") else None - async with httpx.AsyncClient(headers=headers, follow_redirects=True, proxy=proxy) as client: + async with _http_client(client, user_agent=user_agent, proxy=proxy, timeout=timeout) as http_client: try: - res2 = await client.get( + res2 = await http_client.get( url=str(res.callback), params={ "k1": res.k1, "address": lnaddress.address, }, - timeout=timeout or TIMEOUT, ) res2.raise_for_status() except httpx.ConnectError as exc: diff --git a/tests/test_core.py b/tests/test_core.py index f902b2e..fc4db34 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,9 @@ +from unittest.mock import AsyncMock + import httpx import pytest +import lnurl.core as core from lnurl.core import decode, encode, execute_address_request, execute_login, execute_pay_request, get, handle from lnurl.exceptions import InvalidLnurl, InvalidUrl, LnurlResponseException from lnurl.models import ( @@ -106,6 +109,48 @@ async def test_get_requests_error(self, url): with pytest.raises(LnurlResponseException): await get(url) + @pytest.mark.asyncio + async def test_handle_uses_provided_client(self): + requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(302, headers={"Location": "http://127.0.0.1"}) + + client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + follow_redirects=False, + timeout=17, + ) + try: + with pytest.raises(LnurlResponseException): + await handle("https://example.com", client=client) + + assert len(requests) == 1 + assert set(requests[0].extensions["timeout"].values()) == {17} + assert not client.is_closed + finally: + await client.aclose() + + @pytest.mark.asyncio + async def test_execute_passes_provided_client_to_callback(self, monkeypatch): + class LoginResponse: + tag = "login" + + response = LoginResponse() + expected = LnurlSuccessResponse() + handle_mock = AsyncMock(return_value=response) + login_mock = AsyncMock(return_value=expected) + + async with httpx.AsyncClient() as client: + monkeypatch.setattr(core, "LnurlAuthResponse", LoginResponse) + monkeypatch.setattr(core, "handle", handle_mock) + monkeypatch.setattr(core, "execute_login", login_mock) + + assert await core.execute("lnurl", "secret", client=client) is expected + assert handle_mock.await_args.kwargs["client"] is client + assert login_mock.await_args.kwargs["client"] is client + class TestPayFlow: """Full LNURL-pay flow interacting with https://demo.lnbits.com/"""