diff --git a/.fern/metadata.json b/.fern/metadata.json index e8e81e2..8b0edc5 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,7 +1,7 @@ { - "cliVersion": "3.5.0", + "cliVersion": "3.29.1", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "4.45.0", + "generatorVersion": "4.46.6", "generatorConfig": { "client": { "class_name": "Client", @@ -9,5 +9,6 @@ "exported_class_name": "Pipedream", "exported_filename": "pipedream.py" } - } + }, + "sdkVersion": "1.1.4" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 30a012f..4b2288a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "pipedream" -version = "1.1.3" +version = "1.1.4" description = "" readme = "README.md" authors = [] diff --git a/src/pipedream/actions/client.py b/src/pipedream/actions/client.py index 14e4eaa..7846394 100644 --- a/src/pipedream/actions/client.py +++ b/src/pipedream/actions/client.py @@ -74,7 +74,7 @@ def list( Returns ------- SyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission Examples -------- @@ -425,7 +425,7 @@ async def list( Returns ------- AsyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission Examples -------- diff --git a/src/pipedream/actions/raw_client.py b/src/pipedream/actions/raw_client.py index c8a464d..028cf72 100644 --- a/src/pipedream/actions/raw_client.py +++ b/src/pipedream/actions/raw_client.py @@ -71,7 +71,7 @@ def list( Returns ------- SyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission """ _response = self._client_wrapper.httpx_client.request( f"v1/connect/{jsonable_encoder(self._client_wrapper._project_id)}/actions", @@ -523,7 +523,7 @@ async def list( Returns ------- AsyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission """ _response = await self._client_wrapper.httpx_client.request( f"v1/connect/{jsonable_encoder(self._client_wrapper._project_id)}/actions", diff --git a/src/pipedream/client.py b/src/pipedream/client.py index 0dd370e..1749473 100644 --- a/src/pipedream/client.py +++ b/src/pipedream/client.py @@ -6,7 +6,7 @@ import typing import httpx -from .types.project_environment import ProjectEnvironment +from ._.types.project_environment import ProjectEnvironment from .core.api_error import ApiError from .core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from .core.oauth_token_provider import AsyncOAuthTokenProvider, OAuthTokenProvider @@ -99,6 +99,7 @@ def __init__( environment: PipedreamEnvironment = PipedreamEnvironment.PROD, project_id: str, project_environment: typing.Optional[ProjectEnvironment] = os.getenv("PIPEDREAM_PROJECT_ENVIRONMENT"), + headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.Client] = None, @@ -113,6 +114,7 @@ def __init__( environment: PipedreamEnvironment = PipedreamEnvironment.PROD, project_id: str, project_environment: typing.Optional[ProjectEnvironment] = os.getenv("PIPEDREAM_PROJECT_ENVIRONMENT"), + headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.Client] = None, @@ -125,6 +127,7 @@ def __init__( environment: PipedreamEnvironment = PipedreamEnvironment.PROD, project_id: str, project_environment: typing.Optional[ProjectEnvironment] = os.getenv("PIPEDREAM_PROJECT_ENVIRONMENT"), + headers: typing.Optional[typing.Dict[str, str]] = None, client_id: typing.Optional[str] = os.getenv("PIPEDREAM_CLIENT_ID"), client_secret: typing.Optional[str] = os.getenv("PIPEDREAM_CLIENT_SECRET"), token: typing.Optional[typing.Callable[[], str]] = None, @@ -141,6 +144,7 @@ def __init__( base_url=_get_base_url(base_url=base_url, environment=environment), project_id=project_id, project_environment=project_environment, + headers=headers, httpx_client=httpx_client if httpx_client is not None else httpx.Client(timeout=_defaulted_timeout, follow_redirects=follow_redirects) @@ -157,6 +161,7 @@ def __init__( base_url=_get_base_url(base_url=base_url, environment=environment), project_id=project_id, project_environment=project_environment, + headers=headers, httpx_client=httpx.Client(timeout=_defaulted_timeout, follow_redirects=follow_redirects) if follow_redirects is not None else httpx.Client(timeout=_defaulted_timeout), @@ -167,6 +172,7 @@ def __init__( base_url=_get_base_url(base_url=base_url, environment=environment), project_id=project_id, project_environment=project_environment, + headers=headers, token=_token_getter_override if _token_getter_override is not None else oauth_token_provider.get_token, httpx_client=httpx_client if httpx_client is not None @@ -369,6 +375,7 @@ def __init__( environment: PipedreamEnvironment = PipedreamEnvironment.PROD, project_id: str, project_environment: typing.Optional[ProjectEnvironment] = os.getenv("PIPEDREAM_PROJECT_ENVIRONMENT"), + headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.AsyncClient] = None, @@ -383,6 +390,7 @@ def __init__( environment: PipedreamEnvironment = PipedreamEnvironment.PROD, project_id: str, project_environment: typing.Optional[ProjectEnvironment] = os.getenv("PIPEDREAM_PROJECT_ENVIRONMENT"), + headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.AsyncClient] = None, @@ -395,6 +403,7 @@ def __init__( environment: PipedreamEnvironment = PipedreamEnvironment.PROD, project_id: str, project_environment: typing.Optional[ProjectEnvironment] = os.getenv("PIPEDREAM_PROJECT_ENVIRONMENT"), + headers: typing.Optional[typing.Dict[str, str]] = None, client_id: typing.Optional[str] = os.getenv("PIPEDREAM_CLIENT_ID"), client_secret: typing.Optional[str] = os.getenv("PIPEDREAM_CLIENT_SECRET"), token: typing.Optional[typing.Callable[[], str]] = None, @@ -411,6 +420,7 @@ def __init__( base_url=_get_base_url(base_url=base_url, environment=environment), project_id=project_id, project_environment=project_environment, + headers=headers, httpx_client=httpx_client if httpx_client is not None else httpx.AsyncClient(timeout=_defaulted_timeout, follow_redirects=follow_redirects) @@ -427,6 +437,7 @@ def __init__( base_url=_get_base_url(base_url=base_url, environment=environment), project_id=project_id, project_environment=project_environment, + headers=headers, httpx_client=httpx.AsyncClient(timeout=_defaulted_timeout, follow_redirects=follow_redirects) if follow_redirects is not None else httpx.AsyncClient(timeout=_defaulted_timeout), @@ -437,6 +448,7 @@ def __init__( base_url=_get_base_url(base_url=base_url, environment=environment), project_id=project_id, project_environment=project_environment, + headers=headers, token=_token_getter_override, async_token=oauth_token_provider.get_token, httpx_client=httpx_client diff --git a/src/pipedream/components/client.py b/src/pipedream/components/client.py index 1e8afde..95cf678 100644 --- a/src/pipedream/components/client.py +++ b/src/pipedream/components/client.py @@ -77,7 +77,7 @@ def list( Returns ------- SyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission Examples -------- @@ -374,7 +374,7 @@ async def list( Returns ------- AsyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission Examples -------- diff --git a/src/pipedream/components/raw_client.py b/src/pipedream/components/raw_client.py index 8209c3d..b3e39bf 100644 --- a/src/pipedream/components/raw_client.py +++ b/src/pipedream/components/raw_client.py @@ -74,7 +74,7 @@ def list( Returns ------- SyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission """ _response = self._client_wrapper.httpx_client.request( f"v1/connect/{jsonable_encoder(self._client_wrapper._project_id)}/components", @@ -445,7 +445,7 @@ async def list( Returns ------- AsyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission """ _response = await self._client_wrapper.httpx_client.request( f"v1/connect/{jsonable_encoder(self._client_wrapper._project_id)}/components", diff --git a/src/pipedream/core/client_wrapper.py b/src/pipedream/core/client_wrapper.py index b1967dd..cd7dc5e 100644 --- a/src/pipedream/core/client_wrapper.py +++ b/src/pipedream/core/client_wrapper.py @@ -3,7 +3,7 @@ import typing import httpx -from ..types.project_environment import ProjectEnvironment +from .._.types.project_environment import ProjectEnvironment from .http_client import AsyncHttpClient, HttpClient @@ -27,10 +27,10 @@ def __init__( def get_headers(self) -> typing.Dict[str, str]: headers: typing.Dict[str, str] = { - "User-Agent": "pipedream/1.1.3", + "User-Agent": "pipedream/1.1.4", "X-Fern-Language": "Python", "X-Fern-SDK-Name": "pipedream", - "X-Fern-SDK-Version": "1.1.3", + "X-Fern-SDK-Version": "1.1.4", **(self.get_custom_headers() or {}), } if self._project_environment is not None: diff --git a/src/pipedream/core/http_client.py b/src/pipedream/core/http_client.py index f4a7c07..7c6c936 100644 --- a/src/pipedream/core/http_client.py +++ b/src/pipedream/core/http_client.py @@ -5,7 +5,6 @@ import re import time import typing -import urllib.parse from contextlib import asynccontextmanager, contextmanager from random import random @@ -123,6 +122,30 @@ def _should_retry(response: httpx.Response) -> bool: return response.status_code >= 500 or response.status_code in retryable_400s +def _build_url(base_url: str, path: typing.Optional[str]) -> str: + """ + Build a full URL by joining a base URL with a path. + + This function correctly handles base URLs that contain path prefixes (e.g., tenant-based URLs) + by using string concatenation instead of urllib.parse.urljoin(), which would incorrectly + strip path components when the path starts with '/'. + + Example: + >>> _build_url("https://cloud.example.com/org/tenant/api", "/users") + 'https://cloud.example.com/org/tenant/api/users' + + Args: + base_url: The base URL, which may contain path prefixes. + path: The path to append. Can be None or empty string. + + Returns: + The full URL with base_url and path properly joined. + """ + if not path: + return base_url + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" + + def _maybe_filter_none_from_multipart_data( data: typing.Optional[typing.Any], request_files: typing.Optional[RequestFiles], @@ -192,8 +215,19 @@ def get_request_body( # If both data and json are None, we send json data in the event extra properties are specified json_body = maybe_filter_request_body(json, request_options, omit) - # If you have an empty JSON body, you should just send None - return (json_body if json_body != {} else None), data_body if data_body != {} else None + has_additional_body_parameters = bool( + request_options is not None and request_options.get("additional_body_parameters") + ) + + # Only collapse empty dict to None when the body was not explicitly provided + # and there are no additional body parameters. This preserves explicit empty + # bodies (e.g., when an endpoint has a request body type but all fields are optional). + if json_body == {} and json is None and not has_additional_body_parameters: + json_body = None + if data_body == {} and data is None and not has_additional_body_parameters: + data_body = None + + return json_body, data_body class HttpClient: @@ -237,7 +271,7 @@ def request( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: @@ -261,9 +295,29 @@ def request( data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + response = self.httpx_client.request( method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), + url=_build_url(base_url, path), headers=jsonable_encoder( remove_none_from_dict( { @@ -273,23 +327,7 @@ def request( } ) ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) or {} - if request_options is not None - else {} - ), - }, - omit, - ) - ) - ) - ), + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -297,9 +335,9 @@ def request( timeout=timeout, ) - max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 if _should_retry(response=response): - if max_retries > retries: + if retries < max_retries: time.sleep(_retry_timeout(response=response, retries=retries)) return self.request( path=path, @@ -336,7 +374,7 @@ def stream( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> typing.Iterator[httpx.Response]: @@ -360,9 +398,29 @@ def stream( data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + with self.httpx_client.stream( method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), + url=_build_url(base_url, path), headers=jsonable_encoder( remove_none_from_dict( { @@ -372,23 +430,7 @@ def stream( } ) ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) - if request_options is not None - else {} - ), - }, - omit, - ) - ) - ) - ), + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -446,7 +488,7 @@ async def request( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: @@ -473,10 +515,30 @@ async def request( # Get headers (supports async token providers) _headers = await self._get_headers() + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, + ) + ) + ) + ) + # Add the input to each of these and do None-safety checks response = await self.httpx_client.request( method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), + url=_build_url(base_url, path), headers=jsonable_encoder( remove_none_from_dict( { @@ -486,23 +548,7 @@ async def request( } ) ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) or {} - if request_options is not None - else {} - ), - }, - omit, - ) - ) - ) - ), + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -510,9 +556,9 @@ async def request( timeout=timeout, ) - max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 if _should_retry(response=response): - if max_retries > retries: + if retries < max_retries: await asyncio.sleep(_retry_timeout(response=response, retries=retries)) return await self.request( path=path, @@ -548,7 +594,7 @@ async def stream( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> typing.AsyncIterator[httpx.Response]: @@ -575,9 +621,29 @@ async def stream( # Get headers (supports async token providers) _headers = await self._get_headers() + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( + remove_none_from_dict( + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit=omit, + ) + ) + ) + ) + async with self.httpx_client.stream( method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), + url=_build_url(base_url, path), headers=jsonable_encoder( remove_none_from_dict( { @@ -587,23 +653,7 @@ async def stream( } ) ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) - if request_options is not None - else {} - ), - }, - omit=omit, - ) - ) - ) - ), + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, diff --git a/src/pipedream/triggers/client.py b/src/pipedream/triggers/client.py index 6c578cd..64d8825 100644 --- a/src/pipedream/triggers/client.py +++ b/src/pipedream/triggers/client.py @@ -73,7 +73,7 @@ def list( Returns ------- SyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission Examples -------- @@ -435,7 +435,7 @@ async def list( Returns ------- AsyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission Examples -------- diff --git a/src/pipedream/triggers/raw_client.py b/src/pipedream/triggers/raw_client.py index 85171f1..92e9a65 100644 --- a/src/pipedream/triggers/raw_client.py +++ b/src/pipedream/triggers/raw_client.py @@ -71,7 +71,7 @@ def list( Returns ------- SyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission """ _response = self._client_wrapper.httpx_client.request( f"v1/connect/{jsonable_encoder(self._client_wrapper._project_id)}/triggers", @@ -533,7 +533,7 @@ async def list( Returns ------- AsyncPager[Component, GetComponentsResponse] - behaves like registry=all + returns public + private without permission """ _response = await self._client_wrapper.httpx_client.request( f"v1/connect/{jsonable_encoder(self._client_wrapper._project_id)}/triggers", diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index 50f2759..3576329 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -1,9 +1,49 @@ # This file was auto-generated by Fern from our API Definition. -from pipedream.core.http_client import get_request_body, remove_none_from_dict +from typing import Any, Dict + +import pytest + +from pipedream.core.http_client import ( + AsyncHttpClient, + HttpClient, + _build_url, + get_request_body, + remove_none_from_dict, +) from pipedream.core.request_options import RequestOptions +# Stub clients for testing HttpClient and AsyncHttpClient +class _DummySyncClient: + """A minimal stub for httpx.Client that records request arguments.""" + + def __init__(self) -> None: + self.last_request_kwargs: Dict[str, Any] = {} + + def request(self, **kwargs: Any) -> "_DummyResponse": + self.last_request_kwargs = kwargs + return _DummyResponse() + + +class _DummyAsyncClient: + """A minimal stub for httpx.AsyncClient that records request arguments.""" + + def __init__(self) -> None: + self.last_request_kwargs: Dict[str, Any] = {} + + async def request(self, **kwargs: Any) -> "_DummyResponse": + self.last_request_kwargs = kwargs + return _DummyResponse() + + +class _DummyResponse: + """A minimal stub for httpx.Response.""" + + status_code = 200 + headers: Dict[str, str] = {} + + def get_request_options() -> RequestOptions: return {"additional_body_parameters": {"see you": "later"}} @@ -52,17 +92,30 @@ def test_get_none_request_body() -> None: def test_get_empty_json_request_body() -> None: + """Test that implicit empty bodies (json=None) are collapsed to None.""" unrelated_request_options: RequestOptions = {"max_retries": 3} json_body, data_body = get_request_body(json=None, data=None, request_options=unrelated_request_options, omit=None) assert json_body is None assert data_body is None - json_body_extras, data_body_extras = get_request_body( - json={}, data=None, request_options=unrelated_request_options, omit=None - ) - assert json_body_extras is None - assert data_body_extras is None +def test_explicit_empty_json_body_is_preserved() -> None: + """Test that explicit empty bodies (json={}) are preserved and sent as {}. + + This is important for endpoints where the request body is required but all + fields are optional. The server expects valid JSON ({}) not an empty body. + """ + unrelated_request_options: RequestOptions = {"max_retries": 3} + + # Explicit json={} should be preserved + json_body, data_body = get_request_body(json={}, data=None, request_options=unrelated_request_options, omit=None) + assert json_body == {} + assert data_body is None + + # Explicit data={} should also be preserved + json_body2, data_body2 = get_request_body(json=None, data={}, request_options=unrelated_request_options, omit=None) + assert json_body2 is None + assert data_body2 == {} def test_json_body_preserves_none_values() -> None: @@ -107,3 +160,141 @@ def test_remove_none_from_dict_empty_dict() -> None: def test_remove_none_from_dict_all_none() -> None: """Test that remove_none_from_dict handles dict with all None values.""" assert remove_none_from_dict({"a": None, "b": None}) == {} + + +def test_http_client_does_not_pass_empty_params_list() -> None: + """Test that HttpClient passes params=None when params are empty. + + This prevents httpx from stripping existing query parameters from the URL, + which happens when params=[] or params={} is passed. + """ + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + ) + + # Use a path with query params (e.g., pagination cursor URL) + http_client.request( + path="resource?after=123", + method="GET", + params=None, + request_options=None, + ) + + # We care that httpx receives params=None, not [] or {} + assert "params" in dummy_client.last_request_kwargs + assert dummy_client.last_request_kwargs["params"] is None + + # Verify the query string in the URL is preserved + url = str(dummy_client.last_request_kwargs["url"]) + assert "after=123" in url, f"Expected query param 'after=123' in URL, got: {url}" + + +def test_http_client_passes_encoded_params_when_present() -> None: + """Test that HttpClient passes encoded params when params are provided.""" + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com/resource", + ) + + http_client.request( + path="", + method="GET", + params={"after": "456"}, + request_options=None, + ) + + params = dummy_client.last_request_kwargs["params"] + # For a simple dict, encode_query should give a single (key, value) tuple + assert params == [("after", "456")] + + +@pytest.mark.asyncio +async def test_async_http_client_does_not_pass_empty_params_list() -> None: + """Test that AsyncHttpClient passes params=None when params are empty. + + This prevents httpx from stripping existing query parameters from the URL, + which happens when params=[] or params={} is passed. + """ + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + async_base_headers=None, + ) + + # Use a path with query params (e.g., pagination cursor URL) + await http_client.request( + path="resource?after=123", + method="GET", + params=None, + request_options=None, + ) + + # We care that httpx receives params=None, not [] or {} + assert "params" in dummy_client.last_request_kwargs + assert dummy_client.last_request_kwargs["params"] is None + + # Verify the query string in the URL is preserved + url = str(dummy_client.last_request_kwargs["url"]) + assert "after=123" in url, f"Expected query param 'after=123' in URL, got: {url}" + + +@pytest.mark.asyncio +async def test_async_http_client_passes_encoded_params_when_present() -> None: + """Test that AsyncHttpClient passes encoded params when params are provided.""" + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com/resource", + async_base_headers=None, + ) + + await http_client.request( + path="", + method="GET", + params={"after": "456"}, + request_options=None, + ) + + params = dummy_client.last_request_kwargs["params"] + # For a simple dict, encode_query should give a single (key, value) tuple + assert params == [("after", "456")] + + +def test_basic_url_joining() -> None: + """Test basic URL joining with a simple base URL and path.""" + result = _build_url("https://api.example.com", "/users") + assert result == "https://api.example.com/users" + + +def test_basic_url_joining_trailing_slash() -> None: + """Test basic URL joining with a simple base URL and path.""" + result = _build_url("https://api.example.com/", "/users") + assert result == "https://api.example.com/users" + + +def test_preserves_base_url_path_prefix() -> None: + """Test that path prefixes in base URL are preserved. + + This is the critical bug fix - urllib.parse.urljoin() would strip + the path prefix when the path starts with '/'. + """ + result = _build_url("https://cloud.example.com/org/tenant/api", "/users") + assert result == "https://cloud.example.com/org/tenant/api/users" + + +def test_preserves_base_url_path_prefix_trailing_slash() -> None: + """Test that path prefixes in base URL are preserved.""" + result = _build_url("https://cloud.example.com/org/tenant/api/", "/users") + assert result == "https://cloud.example.com/org/tenant/api/users"