Skip to content

Commit 7026006

Browse files
feat(client): add custom JSON encoder for extended type support
1 parent 3cf2c19 commit 7026006

File tree

4 files changed

+169
-5
lines changed

4 files changed

+169
-5
lines changed

src/surge/_base_client.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
APIConnectionError,
8787
APIResponseValidationError,
8888
)
89+
from ._utils._json import openapi_dumps
8990

9091
log: logging.Logger = logging.getLogger(__name__)
9192

@@ -554,8 +555,10 @@ def _build_request(
554555
kwargs["content"] = options.content
555556
elif isinstance(json_data, bytes):
556557
kwargs["content"] = json_data
557-
else:
558-
kwargs["json"] = json_data if is_given(json_data) else None
558+
elif not files:
559+
# Don't set content when JSON is sent as multipart/form-data,
560+
# since httpx's content param overrides other body arguments
561+
kwargs["content"] = openapi_dumps(json_data) if is_given(json_data) and json_data is not None else None
559562
kwargs["files"] = files
560563
else:
561564
headers.pop("Content-Type", None)

src/surge/_compat.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ def model_dump(
139139
exclude_defaults: bool = False,
140140
warnings: bool = True,
141141
mode: Literal["json", "python"] = "python",
142+
by_alias: bool | None = None,
142143
) -> dict[str, Any]:
143144
if (not PYDANTIC_V1) or hasattr(model, "model_dump"):
144145
return model.model_dump(
@@ -148,13 +149,12 @@ def model_dump(
148149
exclude_defaults=exclude_defaults,
149150
# warnings are not supported in Pydantic v1
150151
warnings=True if PYDANTIC_V1 else warnings,
152+
by_alias=by_alias,
151153
)
152154
return cast(
153155
"dict[str, Any]",
154156
model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast]
155-
exclude=exclude,
156-
exclude_unset=exclude_unset,
157-
exclude_defaults=exclude_defaults,
157+
exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias)
158158
),
159159
)
160160

src/surge/_utils/_json.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import json
2+
from typing import Any
3+
from datetime import datetime
4+
from typing_extensions import override
5+
6+
import pydantic
7+
8+
from .._compat import model_dump
9+
10+
11+
def openapi_dumps(obj: Any) -> bytes:
12+
"""
13+
Serialize an object to UTF-8 encoded JSON bytes.
14+
15+
Extends the standard json.dumps with support for additional types
16+
commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc.
17+
"""
18+
return json.dumps(
19+
obj,
20+
cls=_CustomEncoder,
21+
# Uses the same defaults as httpx's JSON serialization
22+
ensure_ascii=False,
23+
separators=(",", ":"),
24+
allow_nan=False,
25+
).encode()
26+
27+
28+
class _CustomEncoder(json.JSONEncoder):
29+
@override
30+
def default(self, o: Any) -> Any:
31+
if isinstance(o, datetime):
32+
return o.isoformat()
33+
if isinstance(o, pydantic.BaseModel):
34+
return model_dump(o, exclude_unset=True, mode="json", by_alias=True)
35+
return super().default(o)

tests/test_utils/test_json.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
from __future__ import annotations
2+
3+
import datetime
4+
from typing import Union
5+
6+
import pydantic
7+
8+
from surge import _compat
9+
from surge._utils._json import openapi_dumps
10+
11+
12+
class TestOpenapiDumps:
13+
def test_basic(self) -> None:
14+
data = {"key": "value", "number": 42}
15+
json_bytes = openapi_dumps(data)
16+
assert json_bytes == b'{"key":"value","number":42}'
17+
18+
def test_datetime_serialization(self) -> None:
19+
dt = datetime.datetime(2023, 1, 1, 12, 0, 0)
20+
data = {"datetime": dt}
21+
json_bytes = openapi_dumps(data)
22+
assert json_bytes == b'{"datetime":"2023-01-01T12:00:00"}'
23+
24+
def test_pydantic_model_serialization(self) -> None:
25+
class User(pydantic.BaseModel):
26+
first_name: str
27+
last_name: str
28+
age: int
29+
30+
model_instance = User(first_name="John", last_name="Kramer", age=83)
31+
data = {"model": model_instance}
32+
json_bytes = openapi_dumps(data)
33+
assert json_bytes == b'{"model":{"first_name":"John","last_name":"Kramer","age":83}}'
34+
35+
def test_pydantic_model_with_default_values(self) -> None:
36+
class User(pydantic.BaseModel):
37+
name: str
38+
role: str = "user"
39+
active: bool = True
40+
score: int = 0
41+
42+
model_instance = User(name="Alice")
43+
data = {"model": model_instance}
44+
json_bytes = openapi_dumps(data)
45+
assert json_bytes == b'{"model":{"name":"Alice"}}'
46+
47+
def test_pydantic_model_with_default_values_overridden(self) -> None:
48+
class User(pydantic.BaseModel):
49+
name: str
50+
role: str = "user"
51+
active: bool = True
52+
53+
model_instance = User(name="Bob", role="admin", active=False)
54+
data = {"model": model_instance}
55+
json_bytes = openapi_dumps(data)
56+
assert json_bytes == b'{"model":{"name":"Bob","role":"admin","active":false}}'
57+
58+
def test_pydantic_model_with_alias(self) -> None:
59+
class User(pydantic.BaseModel):
60+
first_name: str = pydantic.Field(alias="firstName")
61+
last_name: str = pydantic.Field(alias="lastName")
62+
63+
model_instance = User(firstName="John", lastName="Doe")
64+
data = {"model": model_instance}
65+
json_bytes = openapi_dumps(data)
66+
assert json_bytes == b'{"model":{"firstName":"John","lastName":"Doe"}}'
67+
68+
def test_pydantic_model_with_alias_and_default(self) -> None:
69+
class User(pydantic.BaseModel):
70+
user_name: str = pydantic.Field(alias="userName")
71+
user_role: str = pydantic.Field(default="member", alias="userRole")
72+
is_active: bool = pydantic.Field(default=True, alias="isActive")
73+
74+
model_instance = User(userName="charlie")
75+
data = {"model": model_instance}
76+
json_bytes = openapi_dumps(data)
77+
assert json_bytes == b'{"model":{"userName":"charlie"}}'
78+
79+
model_with_overrides = User(userName="diana", userRole="admin", isActive=False)
80+
data = {"model": model_with_overrides}
81+
json_bytes = openapi_dumps(data)
82+
assert json_bytes == b'{"model":{"userName":"diana","userRole":"admin","isActive":false}}'
83+
84+
def test_pydantic_model_with_nested_models_and_defaults(self) -> None:
85+
class Address(pydantic.BaseModel):
86+
street: str
87+
city: str = "Unknown"
88+
89+
class User(pydantic.BaseModel):
90+
name: str
91+
address: Address
92+
verified: bool = False
93+
94+
if _compat.PYDANTIC_V1:
95+
# to handle forward references in Pydantic v1
96+
User.update_forward_refs(**locals()) # type: ignore[reportDeprecated]
97+
98+
address = Address(street="123 Main St")
99+
user = User(name="Diana", address=address)
100+
data = {"user": user}
101+
json_bytes = openapi_dumps(data)
102+
assert json_bytes == b'{"user":{"name":"Diana","address":{"street":"123 Main St"}}}'
103+
104+
address_with_city = Address(street="456 Oak Ave", city="Boston")
105+
user_verified = User(name="Eve", address=address_with_city, verified=True)
106+
data = {"user": user_verified}
107+
json_bytes = openapi_dumps(data)
108+
assert (
109+
json_bytes == b'{"user":{"name":"Eve","address":{"street":"456 Oak Ave","city":"Boston"},"verified":true}}'
110+
)
111+
112+
def test_pydantic_model_with_optional_fields(self) -> None:
113+
class User(pydantic.BaseModel):
114+
name: str
115+
email: Union[str, None]
116+
phone: Union[str, None]
117+
118+
model_with_none = User(name="Eve", email=None, phone=None)
119+
data = {"model": model_with_none}
120+
json_bytes = openapi_dumps(data)
121+
assert json_bytes == b'{"model":{"name":"Eve","email":null,"phone":null}}'
122+
123+
model_with_values = User(name="Frank", email="frank@example.com", phone=None)
124+
data = {"model": model_with_values}
125+
json_bytes = openapi_dumps(data)
126+
assert json_bytes == b'{"model":{"name":"Frank","email":"frank@example.com","phone":null}}'

0 commit comments

Comments
 (0)