Skip to content

Commit 601fef1

Browse files
cursoragentAaronDDM
andcommitted
Fix: Preserve UTF-8 characters in API requests
Co-authored-by: aaron.d <aaron.d@nylas.com>
1 parent 3cb4996 commit 601fef1

File tree

3 files changed

+132
-15
lines changed

3 files changed

+132
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ v6.13.0
55
----------
66
* Fixed from field handling in messages.send() to properly map "from_" field to "from field
77
* Fixed content_id handling for large inline attachments to use content_id as field name instead of generic file{index}
8-
* Fixed UTF-8 character encoding in email subjects and bodies when sending messages or creating drafts with large attachments (>3MB)
8+
* Fixed UTF-8 character encoding for all API requests to preserve special characters (accented letters, emoji, etc.) instead of escaping them as unicode sequences
99

1010
v6.12.0
1111
----------

nylas/handler/http_client.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sys
2+
import json
23
from typing import Union, Tuple, Dict
34
from urllib.parse import urlparse, quote
45

@@ -88,14 +89,20 @@ def _execute(
8889
timeout = self.timeout
8990
if overrides and overrides.get("timeout"):
9091
timeout = overrides["timeout"]
92+
93+
# Serialize request_body to JSON with ensure_ascii=False to preserve UTF-8 characters
94+
# This ensures special characters (accented letters, emoji, etc.) are not escaped
95+
json_data = None
96+
if request_body is not None and data is None:
97+
json_data = json.dumps(request_body, ensure_ascii=False)
98+
9199
try:
92100
response = requests.request(
93101
request["method"],
94102
request["url"],
95103
headers=request["headers"],
96-
json=request_body,
104+
data=json_data or data,
97105
timeout=timeout,
98-
data=data,
99106
)
100107
except requests.exceptions.Timeout as exc:
101108
raise NylasSdkTimeoutError(url=request["url"], timeout=timeout) from exc
@@ -186,6 +193,6 @@ def _build_headers(
186193
if data is not None and data.content_type is not None:
187194
headers["Content-type"] = data.content_type
188195
elif response_body is not None:
189-
headers["Content-type"] = "application/json"
196+
headers["Content-type"] = "application/json; charset=utf-8"
190197

191198
return {**headers, **extra_headers, **override_headers}

tests/handler/test_http_client.py

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def test_build_headers_json_body(self, http_client, patched_version_and_sys):
6363
"X-Nylas-API-Wrapper": "python",
6464
"User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
6565
"Authorization": "Bearer test-key",
66-
"Content-type": "application/json",
66+
"Content-type": "application/json; charset=utf-8",
6767
}
6868

6969
def test_build_headers_form_body(self, http_client, patched_version_and_sys):
@@ -200,7 +200,7 @@ def test_execute_download_request_override_timeout(
200200
"X-Nylas-API-Wrapper": "python",
201201
"User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
202202
"Authorization": "Bearer test-key",
203-
"Content-type": "application/json",
203+
"Content-type": "application/json; charset=utf-8",
204204
},
205205
timeout=60,
206206
stream=False,
@@ -299,12 +299,11 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request):
299299
"X-Nylas-API-Wrapper": "python",
300300
"User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
301301
"Authorization": "Bearer test-key",
302-
"Content-type": "application/json",
302+
"Content-type": "application/json; charset=utf-8",
303303
"test": "header",
304304
},
305-
json={"foo": "bar"},
305+
data='{"foo": "bar"}',
306306
timeout=30,
307-
data=None,
308307
)
309308

310309
def test_execute_override_timeout(
@@ -334,12 +333,11 @@ def test_execute_override_timeout(
334333
"X-Nylas-API-Wrapper": "python",
335334
"User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
336335
"Authorization": "Bearer test-key",
337-
"Content-type": "application/json",
336+
"Content-type": "application/json; charset=utf-8",
338337
"test": "header",
339338
},
340-
json={"foo": "bar"},
339+
data='{"foo": "bar"}',
341340
timeout=60,
342-
data=None,
343341
)
344342

345343
def test_execute_timeout(self, http_client, mock_session_timeout):
@@ -425,10 +423,122 @@ def test_execute_with_headers(self, http_client, patched_version_and_sys, patche
425423
"X-Nylas-API-Wrapper": "python",
426424
"User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3",
427425
"Authorization": "Bearer test-key",
428-
"Content-type": "application/json",
426+
"Content-type": "application/json; charset=utf-8",
429427
"test": "header",
430428
},
431-
json={"foo": "bar"},
429+
data='{"foo": "bar"}',
432430
timeout=30,
433-
data=None,
434431
)
432+
433+
def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys, patched_request):
434+
"""Test that UTF-8 characters are preserved in JSON requests (not escaped)."""
435+
mock_response = Mock()
436+
mock_response.json.return_value = {"success": True}
437+
mock_response.headers = {"X-Test-Header": "test"}
438+
mock_response.status_code = 200
439+
patched_request.return_value = mock_response
440+
441+
# Request with special characters
442+
request_body = {
443+
"title": "Réunion d'équipe",
444+
"description": "De l'idée à la post-prod, sans friction",
445+
"location": "café",
446+
}
447+
448+
response_json, response_headers = http_client._execute(
449+
method="POST",
450+
path="/events",
451+
request_body=request_body,
452+
)
453+
454+
assert response_json == {"success": True}
455+
# Verify that the data sent preserves UTF-8 characters (not escaped)
456+
call_kwargs = patched_request.call_args[1]
457+
assert "data" in call_kwargs
458+
sent_data = call_kwargs["data"]
459+
460+
# The JSON should contain actual UTF-8 characters, not escape sequences
461+
assert "Réunion d'équipe" in sent_data
462+
assert "De l'idée à la post-prod" in sent_data
463+
assert "café" in sent_data
464+
# Should NOT contain unicode escape sequences
465+
assert "\\u" not in sent_data
466+
467+
def test_execute_with_none_request_body(self, http_client, patched_version_and_sys, patched_request):
468+
"""Test that None request_body is handled correctly."""
469+
mock_response = Mock()
470+
mock_response.json.return_value = {"success": True}
471+
mock_response.headers = {"X-Test-Header": "test"}
472+
mock_response.status_code = 200
473+
patched_request.return_value = mock_response
474+
475+
response_json, response_headers = http_client._execute(
476+
method="GET",
477+
path="/events",
478+
request_body=None,
479+
)
480+
481+
assert response_json == {"success": True}
482+
# Verify that data is None when request_body is None
483+
call_kwargs = patched_request.call_args[1]
484+
assert "data" in call_kwargs
485+
assert call_kwargs["data"] is None
486+
487+
def test_execute_with_emoji_and_international_characters(self, http_client, patched_version_and_sys, patched_request):
488+
"""Test that emoji and various international characters are preserved."""
489+
mock_response = Mock()
490+
mock_response.json.return_value = {"success": True}
491+
mock_response.headers = {"X-Test-Header": "test"}
492+
mock_response.status_code = 200
493+
patched_request.return_value = mock_response
494+
495+
request_body = {
496+
"emoji": "🎉 Party time! 🥳",
497+
"japanese": "こんにちは",
498+
"chinese": "你好",
499+
"russian": "Привет",
500+
"german": "Größe",
501+
"spanish": "¿Cómo estás?",
502+
}
503+
504+
response_json, response_headers = http_client._execute(
505+
method="POST",
506+
path="/messages",
507+
request_body=request_body,
508+
)
509+
510+
assert response_json == {"success": True}
511+
call_kwargs = patched_request.call_args[1]
512+
sent_data = call_kwargs["data"]
513+
514+
# All characters should be preserved
515+
assert "🎉 Party time! 🥳" in sent_data
516+
assert "こんにちは" in sent_data
517+
assert "你好" in sent_data
518+
assert "Привет" in sent_data
519+
assert "Größe" in sent_data
520+
assert "¿Cómo estás?" in sent_data
521+
522+
def test_execute_with_multipart_data_not_affected(self, http_client, patched_version_and_sys, patched_request):
523+
"""Test that multipart/form-data is not affected by the change."""
524+
mock_response = Mock()
525+
mock_response.json.return_value = {"success": True}
526+
mock_response.headers = {"X-Test-Header": "test"}
527+
mock_response.status_code = 200
528+
patched_request.return_value = mock_response
529+
530+
# When data is provided (multipart), request_body should be ignored
531+
mock_data = Mock()
532+
mock_data.content_type = "multipart/form-data"
533+
534+
response_json, response_headers = http_client._execute(
535+
method="POST",
536+
path="/messages/send",
537+
request_body={"foo": "bar"}, # This should be ignored
538+
data=mock_data,
539+
)
540+
541+
assert response_json == {"success": True}
542+
call_kwargs = patched_request.call_args[1]
543+
# Should use the multipart data, not JSON
544+
assert call_kwargs["data"] == mock_data

0 commit comments

Comments
 (0)