Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
### v3.18.1 (2026-02-16)
* * *

### Bug Fixes:
- Fixed enum serialization in JSON requests to use actual enum values instead of string representations.

### v3.18.0 (2026-02-06)
* * *
### New Attributes:
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.18.0
3.18.1
3 changes: 2 additions & 1 deletion chargebee/http_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def request(

retry_config = env.get_retry_config() if hasattr(env, "get_retry_config") else None
url = env.api_url(url, subDomain)
if isJsonRequest:
params = util.convert_to_serializable(params)

match method.lower(), isJsonRequest:
case "get" | "head" | "delete", _:
Expand All @@ -58,7 +60,6 @@ def request(
case _, False:
headers["Content-Type"] = "application/x-www-form-urlencoded"
request_args["data"] = params

headers.update(
{
"User-Agent": f"Chargebee-Python-Client v{VERSION}",
Expand Down
15 changes: 15 additions & 0 deletions chargebee/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from collections import OrderedDict
from enum import Enum
from typing import Any, Dict


def serialize(value, prefix=None, idx=None, jsonKeys=None, level=0):
Expand Down Expand Up @@ -87,3 +88,17 @@ def generate_uuid_v4() -> str:
hex_str = "".join(f"{byte:02x}" for byte in byte_array)

return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"


def convert_to_serializable(obj: Any) -> Any:
"""
Recursively convert TypedDict and enums to JSON-serializable format.
"""
if isinstance(obj, Enum):
return obj.value
elif isinstance(obj, dict):
return {key: convert_to_serializable(value) for key, value in obj.items()}
elif isinstance(obj, (list, tuple)):
return type(obj)(convert_to_serializable(item) for item in obj)
else:
return obj
2 changes: 1 addition & 1 deletion chargebee/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "3.18.0"
VERSION = "3.18.1"
76 changes: 76 additions & 0 deletions tests/test_http_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import unittest
import asyncio
from unittest.mock import patch, Mock, AsyncMock
from enum import Enum

from chargebee import environment
from chargebee.api_error import InvalidRequestError
Expand Down Expand Up @@ -276,3 +277,78 @@ def test_subdomain_url(self, mock_client_class):
self.assertEqual(
call_args[1]["url"], "https://test_site.ingest.chargebee.com/api/v2/test?"
)

@patch("httpx.Client")
def test_json_request_with_enum_serialization(self, mock_client_class):
"""Test that enums in JSON requests are converted to their values"""
mock_client, mock_response = make_mock_client(
text=json.dumps({"message": "success"})
)
mock_client.request.return_value = mock_response
mock_client_class.return_value.__enter__.return_value = mock_client

from chargebee.http_request import request

class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"

class Priority(Enum):
HIGH = 1
MEDIUM = 2
LOW = 3

# Test with nested structure containing enums
test_data = {
"name": "test_user",
"status": Status.ACTIVE,
"priority": Priority.HIGH,
"nested": {
"status": Status.INACTIVE,
"values": [Priority.LOW, Priority.MEDIUM],
},
"items": (
{"status": Status.ACTIVE, "priority": Priority.HIGH},
{"status": Status.INACTIVE, "priority": Priority.LOW},
),
}

request(
"POST", "/test", MockEnvironment(), params=test_data, isJsonRequest=True
)

# Verify that the request was made with converted enum values
call_args = mock_client.request.call_args
json_data = call_args[1]["json"]

# Check that enums are converted to their values
self.assertEqual(json_data["status"], "active")
self.assertEqual(json_data["priority"], 1)
self.assertEqual(json_data["nested"]["status"], "inactive")
self.assertEqual(json_data["nested"]["values"], [3, 2])
self.assertEqual(json_data["items"][0]["status"], "active")
self.assertEqual(json_data["items"][0]["priority"], 1)
self.assertEqual(json_data["items"][1]["status"], "inactive")
self.assertEqual(json_data["items"][1]["priority"], 3)

@patch("httpx.Client")
def test_form_request_without_enum_conversion(self, mock_client_class):
"""Test that form requests (non-JSON) don't use convert_to_serializable"""
mock_client, mock_response = make_mock_client(
text=json.dumps({"message": "success"})
)
mock_client.request.return_value = mock_response
mock_client_class.return_value.__enter__.return_value = mock_client

from chargebee.http_request import request

test_data = {"key": "value", "number": 42}
request(
"POST", "/test", MockEnvironment(), params=test_data, isJsonRequest=False
)

# Verify that form requests use data parameter
call_args = mock_client.request.call_args
self.assertIn("data", call_args[1])
self.assertNotIn("json", call_args[1])
self.assertEqual(call_args[1]["data"], test_data)
190 changes: 190 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import unittest
from enum import Enum

from chargebee import util

Expand Down Expand Up @@ -42,3 +43,192 @@ def test_serialize(self):
}

self.assertEqual(after, util.serialize(before))

def test_convert_to_serializable_with_enum(self):
"""Test that enums are converted to their values"""
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"

result = util.convert_to_serializable(Status.ACTIVE)
self.assertEqual(result, "active")

def test_convert_to_serializable_with_dict_containing_enum(self):
"""Test that enums in dicts are converted to their values"""
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"

input_dict = {
"name": "test",
"status": Status.ACTIVE,
"count": 42,
}

result = util.convert_to_serializable(input_dict)
expected = {
"name": "test",
"status": "active",
"count": 42,
}
self.assertEqual(result, expected)

def test_convert_to_serializable_with_nested_dict(self):
"""Test that nested dicts with enums are handled correctly"""
class Status(Enum):
ACTIVE = "active"

class Type(Enum):
PREMIUM = "premium"

input_dict = {
"user": {
"name": "John",
"status": Status.ACTIVE,
"subscription": {
"type": Type.PREMIUM,
"active": True,
},
},
"count": 10,
}

result = util.convert_to_serializable(input_dict)
expected = {
"user": {
"name": "John",
"status": "active",
"subscription": {
"type": "premium",
"active": True,
},
},
"count": 10,
}
self.assertEqual(result, expected)

def test_convert_to_serializable_with_list(self):
"""Test that lists with enums are converted correctly"""
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"

input_list = [Status.ACTIVE, "test", 42, Status.INACTIVE]
result = util.convert_to_serializable(input_list)
expected = ["active", "test", 42, "inactive"]
self.assertEqual(result, expected)

def test_convert_to_serializable_with_tuple(self):
"""Test that tuples with enums are converted correctly and remain tuples"""
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"

input_tuple = (Status.ACTIVE, "test", 42, Status.INACTIVE)
result = util.convert_to_serializable(input_tuple)
expected = ("active", "test", 42, "inactive")
self.assertEqual(result, expected)
self.assertIsInstance(result, tuple)

def test_convert_to_serializable_with_list_of_dicts(self):
"""Test that lists of dicts with enums are converted correctly"""
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"

input_list = [
{"name": "user1", "status": Status.ACTIVE},
{"name": "user2", "status": Status.INACTIVE},
]

result = util.convert_to_serializable(input_list)
expected = [
{"name": "user1", "status": "active"},
{"name": "user2", "status": "inactive"},
]
self.assertEqual(result, expected)

def test_convert_to_serializable_with_complex_nested_structure(self):
"""Test complex nested structure with enums at various levels"""
class Status(Enum):
ACTIVE = "active"

class Type(Enum):
BASIC = "basic"
PREMIUM = "premium"

input_data = {
"users": [
{
"name": "John",
"status": Status.ACTIVE,
"subscriptions": [
{"type": Type.PREMIUM, "price": 99.99},
{"type": Type.BASIC, "price": 9.99},
],
},
],
"metadata": {
"default_status": Status.ACTIVE,
"types": (Type.BASIC, Type.PREMIUM),
},
}

result = util.convert_to_serializable(input_data)
expected = {
"users": [
{
"name": "John",
"status": "active",
"subscriptions": [
{"type": "premium", "price": 99.99},
{"type": "basic", "price": 9.99},
],
},
],
"metadata": {
"default_status": "active",
"types": ("basic", "premium"),
},
}
self.assertEqual(result, expected)

def test_convert_to_serializable_with_primitive_types(self):
"""Test that primitive types are passed through unchanged"""
test_cases = [
("string", "string"),
(42, 42),
(3.14, 3.14),
(True, True),
(False, False),
(None, None),
]

for input_val, expected_val in test_cases:
result = util.convert_to_serializable(input_val)
self.assertEqual(result, expected_val)

def test_convert_to_serializable_with_empty_structures(self):
"""Test that empty dicts, lists, and tuples are handled correctly"""
self.assertEqual(util.convert_to_serializable({}), {})
self.assertEqual(util.convert_to_serializable([]), [])
self.assertEqual(util.convert_to_serializable(()), ())

def test_convert_to_serializable_with_integer_enum(self):
"""Test that enums with integer values are converted correctly"""
class Priority(Enum):
LOW = 1
MEDIUM = 2
HIGH = 3

input_dict = {
"task": "Test task",
"priority": Priority.HIGH,
}

result = util.convert_to_serializable(input_dict)
expected = {
"task": "Test task",
"priority": 3,
}
self.assertEqual(result, expected)