diff --git a/src/functions/src/supabase_functions/_async/functions_client.py b/src/functions/src/supabase_functions/_async/functions_client.py index 2f7529b1..479c7887 100644 --- a/src/functions/src/supabase_functions/_async/functions_client.py +++ b/src/functions/src/supabase_functions/_async/functions_client.py @@ -6,7 +6,7 @@ from httpx import AsyncClient, HTTPError, QueryParams, Response from yarl import URL -from ..errors import FunctionsHttpError, FunctionsRelayError +from ..errors import FunctionsHttpError, FunctionsRelayError, _error_message_from from ..utils import ( FunctionRegion, is_http_url, @@ -14,6 +14,8 @@ ) from ..version import __version__ +HTTPMethod = Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"] + class AsyncFunctionsClient: def __init__( @@ -77,20 +79,19 @@ def __init__( async def _request( self, - method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + method: HTTPMethod, path: list[str], headers: Optional[Dict[str, str]] = None, - json: Optional[Dict[Any, Any]] = None, + json: Optional[Union[Dict[Any, Any], str, bytes]] = None, params: Optional[QueryParams] = None, ) -> Response: url = self.url.joinpath(*path) - headers = headers or dict() - headers.update(self.headers) + headers = {**self.headers, **(headers or {})} response = ( await self._client.request( - method, str(url), data=json, headers=headers, params=params + method, str(url), content=json, headers=headers, params=params ) - if isinstance(json, str) + if isinstance(json, (str, bytes)) else await self._client.request( method, str(url), json=json, headers=headers, params=params ) @@ -103,7 +104,7 @@ async def _request( status_code = response.status_code raise FunctionsHttpError( - response.json().get("error") + _error_message_from(response) or f"An error occurred while requesting your edge function at {exc.request.url!r}.", status_code, ) from exc @@ -132,17 +133,20 @@ async def invoke( invoke_options : object with the following properties `headers`: object representing the headers to send with the request `body`: the body of the request + `method`: the HTTP method to invoke the function with. The default is `POST` `responseType`: how the response should be parsed. The default is `json` """ if not is_valid_str_arg(function_name): raise ValueError("function_name must a valid string value.") - headers = self.headers + headers: Dict[str, str] = {} params = QueryParams() body = None + method: HTTPMethod = "POST" response_type = "text/plain" if invoke_options is not None: headers.update(invoke_options.get("headers", {})) + method = invoke_options.get("method", "POST") response_type = invoke_options.get("responseType", "text/plain") region = invoke_options.get("region") @@ -161,14 +165,19 @@ async def invoke( headers["Content-Type"] = "text/plain" elif isinstance(body, dict): headers["Content-Type"] = "application/json" + elif isinstance(body, bytes): + headers["Content-Type"] = "application/octet-stream" response = await self._request( - "POST", [function_name], headers=headers, json=body, params=params + method, [function_name], headers=headers, json=body, params=params ) is_relay_error = response.headers.get("x-relay-header") if is_relay_error and is_relay_error == "true": - raise FunctionsRelayError(response.json().get("error")) + raise FunctionsRelayError( + _error_message_from(response) + or "An error occurred while relaying your edge function request." + ) if response_type == "json": data = response.json() diff --git a/src/functions/src/supabase_functions/_sync/functions_client.py b/src/functions/src/supabase_functions/_sync/functions_client.py index 18f073d7..f023684b 100644 --- a/src/functions/src/supabase_functions/_sync/functions_client.py +++ b/src/functions/src/supabase_functions/_sync/functions_client.py @@ -6,7 +6,7 @@ from httpx import Client, HTTPError, QueryParams, Response from yarl import URL -from ..errors import FunctionsHttpError, FunctionsRelayError +from ..errors import FunctionsHttpError, FunctionsRelayError, _error_message_from from ..utils import ( FunctionRegion, is_http_url, @@ -14,6 +14,8 @@ ) from ..version import __version__ +HTTPMethod = Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"] + class SyncFunctionsClient: def __init__( @@ -77,20 +79,19 @@ def __init__( def _request( self, - method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + method: HTTPMethod, path: list[str], headers: Optional[Dict[str, str]] = None, - json: Optional[Dict[Any, Any]] = None, + json: Optional[Union[Dict[Any, Any], str, bytes]] = None, params: Optional[QueryParams] = None, ) -> Response: url = self.url.joinpath(*path) - headers = headers or dict() - headers.update(self.headers) + headers = {**self.headers, **(headers or {})} response = ( self._client.request( - method, str(url), data=json, headers=headers, params=params + method, str(url), content=json, headers=headers, params=params ) - if isinstance(json, str) + if isinstance(json, (str, bytes)) else self._client.request( method, str(url), json=json, headers=headers, params=params ) @@ -103,7 +104,7 @@ def _request( status_code = response.status_code raise FunctionsHttpError( - response.json().get("error") + _error_message_from(response) or f"An error occurred while requesting your edge function at {exc.request.url!r}.", status_code, ) from exc @@ -132,17 +133,20 @@ def invoke( invoke_options : object with the following properties `headers`: object representing the headers to send with the request `body`: the body of the request + `method`: the HTTP method to invoke the function with. The default is `POST` `responseType`: how the response should be parsed. The default is `json` """ if not is_valid_str_arg(function_name): raise ValueError("function_name must a valid string value.") - headers = self.headers + headers: Dict[str, str] = {} params = QueryParams() body = None + method: HTTPMethod = "POST" response_type = "text/plain" if invoke_options is not None: headers.update(invoke_options.get("headers", {})) + method = invoke_options.get("method", "POST") response_type = invoke_options.get("responseType", "text/plain") region = invoke_options.get("region") @@ -161,14 +165,19 @@ def invoke( headers["Content-Type"] = "text/plain" elif isinstance(body, dict): headers["Content-Type"] = "application/json" + elif isinstance(body, bytes): + headers["Content-Type"] = "application/octet-stream" response = self._request( - "POST", [function_name], headers=headers, json=body, params=params + method, [function_name], headers=headers, json=body, params=params ) is_relay_error = response.headers.get("x-relay-header") if is_relay_error and is_relay_error == "true": - raise FunctionsRelayError(response.json().get("error")) + raise FunctionsRelayError( + _error_message_from(response) + or "An error occurred while relaying your edge function request." + ) if response_type == "json": data = response.json() diff --git a/src/functions/src/supabase_functions/errors.py b/src/functions/src/supabase_functions/errors.py index 0529e344..0b7dc855 100644 --- a/src/functions/src/supabase_functions/errors.py +++ b/src/functions/src/supabase_functions/errors.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import TypedDict +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from httpx import Response class FunctionsApiErrorDict(TypedDict): @@ -42,3 +45,18 @@ def __init__(self, message: str, code: int | None = None) -> None: "FunctionsRelayError", 400 if code is None else code, ) + + +def _error_message_from(response: Response) -> str | None: + """Best-effort extraction of an error message from an edge function response. + + An edge function may reply with a plain-text or empty body, so a failed JSON + decode must not mask the underlying HTTP error. + """ + try: + body = response.json() + except ValueError: + return response.text or None + if isinstance(body, dict): + return body.get("error") + return response.text or None diff --git a/src/functions/tests/_async/test_function_client.py b/src/functions/tests/_async/test_function_client.py index a821deb8..3866c251 100644 --- a/src/functions/tests/_async/test_function_client.py +++ b/src/functions/tests/_async/test_function_client.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from httpx import AsyncClient, HTTPError, Response, Timeout +from httpx import AsyncClient, HTTPError, Request, Response, Timeout # Import the class to test from supabase_functions import AsyncFunctionsClient @@ -229,3 +229,185 @@ async def test_init_with_httpx_client() -> None: # Verify the client is properly configured with our custom client assert client._client is custom_client + + +async def test_invoke_does_not_leak_per_call_headers( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke( + "test-function", + { + "headers": {"x-one-off": "yes"}, + "region": FunctionRegion("us-east-1"), + "body": {"key": "value"}, + }, + ) + await client.invoke("test-function") + + first, second = mock_request.call_args_list + assert first.kwargs["headers"]["x-one-off"] == "yes" + assert "x-one-off" not in second.kwargs["headers"] + assert "x-region" not in second.kwargs["headers"] + assert "Content-Type" not in second.kwargs["headers"] + assert "x-one-off" not in client.headers + + +async def test_invoke_per_call_headers_override_client_headers( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke( + "test-function", {"headers": {"Authorization": "Bearer override"}} + ) + + _, kwargs = mock_request.call_args + assert kwargs["headers"]["Authorization"] == "Bearer override" + assert client.headers["Authorization"] == "Bearer valid.jwt.token" + + +@pytest.mark.parametrize("method", ["GET", "PUT", "PATCH", "DELETE"]) +async def test_invoke_with_method(client: AsyncFunctionsClient, method: str) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke("test-function", {"method": method}) + + args, _ = mock_request.call_args + assert args[0] == method + + +async def test_invoke_defaults_to_post(client: AsyncFunctionsClient) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke("test-function") + + args, _ = mock_request.call_args + assert args[0] == "POST" + + +async def test_invoke_with_bytes_body(client: AsyncFunctionsClient) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke("test-function", {"body": b"\x00binary"}) + + _, kwargs = mock_request.call_args + assert kwargs["headers"]["Content-Type"] == "application/octet-stream" + assert kwargs["content"] == b"\x00binary" + + +async def test_invoke_string_body_sent_as_content( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + await client.invoke("test-function", {"body": "string data"}) + + _, kwargs = mock_request.call_args + assert kwargs["content"] == "string data" + + +async def test_invoke_http_error_with_non_json_body( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "boom" + mock_response.raise_for_status.side_effect = HTTPError("HTTP Error") + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises(FunctionsHttpError, match="boom"): + await client.invoke("test-function") + + +async def test_invoke_http_error_with_empty_body(client: AsyncFunctionsClient) -> None: + error = HTTPError("HTTP Error") + error.request = Request("POST", "https://example.com/test-function") + + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "" + mock_response.raise_for_status.side_effect = error + mock_response.headers = {} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises( + FunctionsHttpError, match="An error occurred while requesting" + ): + await client.invoke("test-function") + + +async def test_invoke_relay_error_with_non_json_body( + client: AsyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "relay exploded" + mock_response.raise_for_status = Mock() + mock_response.headers = {"x-relay-header": "true"} + + with patch.object( + client._client, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises(FunctionsRelayError, match="relay exploded"): + await client.invoke("test-function") diff --git a/src/functions/tests/_sync/test_function_client.py b/src/functions/tests/_sync/test_function_client.py index 6be348df..7aa14879 100644 --- a/src/functions/tests/_sync/test_function_client.py +++ b/src/functions/tests/_sync/test_function_client.py @@ -3,7 +3,7 @@ from unittest.mock import Mock, patch import pytest -from httpx import Client, HTTPError, Response, Timeout +from httpx import Client, HTTPError, Request, Response, Timeout # Import the class to test from supabase_functions import SyncFunctionsClient @@ -213,3 +213,167 @@ def test_init_with_httpx_client() -> None: # Verify the client is properly configured with our custom client assert client._client is custom_client + + +def test_invoke_does_not_leak_per_call_headers( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke( + "test-function", + { + "headers": {"x-one-off": "yes"}, + "region": FunctionRegion("us-east-1"), + "body": {"key": "value"}, + }, + ) + client.invoke("test-function") + + first, second = mock_request.call_args_list + assert first.kwargs["headers"]["x-one-off"] == "yes" + assert "x-one-off" not in second.kwargs["headers"] + assert "x-region" not in second.kwargs["headers"] + assert "Content-Type" not in second.kwargs["headers"] + assert "x-one-off" not in client.headers + + +def test_invoke_per_call_headers_override_client_headers( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke( + "test-function", {"headers": {"Authorization": "Bearer override"}} + ) + + _, kwargs = mock_request.call_args + assert kwargs["headers"]["Authorization"] == "Bearer override" + assert client.headers["Authorization"] == "Bearer valid.jwt.token" + + +@pytest.mark.parametrize("method", ["GET", "PUT", "PATCH", "DELETE"]) +def test_invoke_with_method(client: SyncFunctionsClient, method: str) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke("test-function", {"method": method}) + + args, _ = mock_request.call_args + assert args[0] == method + + +def test_invoke_defaults_to_post(client: SyncFunctionsClient) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke("test-function") + + args, _ = mock_request.call_args + assert args[0] == "POST" + + +def test_invoke_with_bytes_body(client: SyncFunctionsClient) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke("test-function", {"body": b"\x00binary"}) + + _, kwargs = mock_request.call_args + assert kwargs["headers"]["Content-Type"] == "application/octet-stream" + assert kwargs["content"] == b"\x00binary" + + +def test_invoke_string_body_sent_as_content( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.return_value = {"message": "success"} + mock_response.raise_for_status = Mock() + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + client.invoke("test-function", {"body": "string data"}) + + _, kwargs = mock_request.call_args + assert kwargs["content"] == "string data" + + +def test_invoke_http_error_with_non_json_body( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "boom" + mock_response.raise_for_status.side_effect = HTTPError("HTTP Error") + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises(FunctionsHttpError, match="boom"): + client.invoke("test-function") + + +def test_invoke_http_error_with_empty_body(client: SyncFunctionsClient) -> None: + error = HTTPError("HTTP Error") + error.request = Request("POST", "https://example.com/test-function") + + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "" + mock_response.raise_for_status.side_effect = error + mock_response.headers = {} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises( + FunctionsHttpError, match="An error occurred while requesting" + ): + client.invoke("test-function") + + +def test_invoke_relay_error_with_non_json_body( + client: SyncFunctionsClient, +) -> None: + mock_response = Mock(spec=Response) + mock_response.json.side_effect = ValueError("not json") + mock_response.text = "relay exploded" + mock_response.raise_for_status = Mock() + mock_response.headers = {"x-relay-header": "true"} + + with patch.object(client._client, "request", new_callable=Mock) as mock_request: + mock_request.return_value = mock_response + + with pytest.raises(FunctionsRelayError, match="relay exploded"): + client.invoke("test-function") diff --git a/src/functions/tests/test_errors.py b/src/functions/tests/test_errors.py index 11eca4e0..fa3d3121 100644 --- a/src/functions/tests/test_errors.py +++ b/src/functions/tests/test_errors.py @@ -1,11 +1,13 @@ -from typing import Type +from typing import Optional, Type import pytest +from httpx import Response from supabase_functions.errors import ( FunctionsApiErrorDict, FunctionsError, FunctionsHttpError, FunctionsRelayError, + _error_message_from, ) @@ -105,3 +107,17 @@ def test_error_message_types() -> None: error = FunctionsError(message, "test", 500) assert error.message == message assert error.to_dict()["message"] == message + + +@pytest.mark.parametrize( + "response,expected", + [ + (Response(500, json={"error": "boom"}), "boom"), + (Response(500, json={"detail": "boom"}), None), + (Response(500, json=["boom"]), '["boom"]'), + (Response(500, text="plain boom"), "plain boom"), + (Response(500), None), + ], +) +def test_error_message_from(response: Response, expected: Optional[str]) -> None: + assert _error_message_from(response) == expected