From 02c902a404001b7f51f108d4412d3d1d207ba47e Mon Sep 17 00:00:00 2001 From: BM Cho Date: Fri, 18 Sep 2026 11:33:12 +0800 Subject: [PATCH] fix: encode resource identifiers as URL path segments --- lago_python_client/services/request.py | 12 +++++++++--- tests/test_customer_client.py | 26 ++++++++++++++++++++++++++ tests/test_request_services.py | 23 +++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/lago_python_client/services/request.py b/lago_python_client/services/request.py index 7c17fb1d..1ba4e5ee 100644 --- a/lago_python_client/services/request.py +++ b/lago_python_client/services/request.py @@ -5,7 +5,7 @@ except ImportError: # Python 3.7 from typing_extensions import Final # type: ignore from collections.abc import Mapping, Sequence -from urllib.parse import urlencode, urljoin +from urllib.parse import quote, urlencode, urljoin import httpx @@ -24,19 +24,25 @@ QueryPairs = Union[Mapping[str, Union[int, str, list[str]]], Sequence[tuple[str, Union[int, str]]]] +def _quote_path_part(part: str) -> str: + # quote() leaves dots unescaped; standalone dot segments are normalized by + # urljoin and HTTP clients, so preserve them as literal identifiers too. + return part.replace(".", "%2E") if part in (".", "..") else quote(part, safe="") + + def make_url( *, origin: str, path_parts: Sequence[str], query_pairs: QueryPairs = None, ) -> str: - """Return url.""" + """Return a URL, treating each path part as an unencoded segment.""" if query_pairs is None: query_pairs = {} return urljoin( origin, URI_TEMPLATE.format( - uri_path="/".join(path_parts), + uri_path="/".join(_quote_path_part(part) for part in path_parts), uri_query=QUERY_TEMPLATE.format( query=urlencode(query_pairs, doseq=True), ) diff --git a/tests/test_customer_client.py b/tests/test_customer_client.py index 9c7ed58a..34011ebc 100644 --- a/tests/test_customer_client.py +++ b/tests/test_customer_client.py @@ -66,6 +66,32 @@ def mock_response(mock="customer"): return customer_response.read() +@pytest.mark.parametrize( + "external_id,encoded", + [ + ("customer#billing", "customer%23billing"), + ("customer?active=true", "customer%3Factive%3Dtrue"), + ("tenant/customer", "tenant%2Fcustomer"), + ("customer%2Fbilling", "customer%252Fbilling"), + (".", "%2E"), + ("..", "%2E%2E"), + ], +) +def test_find_preserves_reserved_characters_in_external_id(httpx_mock: HTTPXMock, external_id, encoded): + client = Client(api_key="test-key") + httpx_mock.add_response( + method="GET", + url=f"https://api.getlago.com/api/v1/customers/{encoded}", + content=mock_response(), + ) + + client.customers.find(external_id) + + request = httpx_mock.get_request() + assert request.headers["Authorization"] == "Bearer test-key" + assert request.url.raw_path == f"/api/v1/customers/{encoded}".encode() + + def test_valid_create_customers_request(httpx_mock: HTTPXMock): client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") diff --git a/tests/test_request_services.py b/tests/test_request_services.py index 2041b638..7817577e 100644 --- a/tests/test_request_services.py +++ b/tests/test_request_services.py @@ -1,5 +1,7 @@ """Test request services.""" +import pytest + from lago_python_client.services.request import ( make_headers, make_url, @@ -11,6 +13,27 @@ from lago_python_client.version import LAGO_VERSION +@pytest.mark.parametrize( + "identifier,encoded", + [ + ("customer#billing", "customer%23billing"), + ("customer?expand=all", "customer%3Fexpand%3Dall"), + ("tenant/customer", "tenant%2Fcustomer"), + ("customer%2Fbilling", "customer%252Fbilling"), + ("customer name", "customer%20name"), + ("客戶", "%E5%AE%A2%E6%88%B6"), + ("..", "%2E%2E"), + (".", "%2E"), + ], +) +def test_make_url_treats_identifiers_as_path_segments(identifier, encoded): + assert make_url( + origin="https://api.getlago.com/api/v1/", + path_parts=("customers", identifier, "current_usage"), + query_pairs={"external_subscription_id": "sub#1"}, + ) == (f"https://api.getlago.com/api/v1/customers/{encoded}/current_usage?external_subscription_id=sub%231") + + def test_make_headers(): """Make headers.""" # Give api_key