Skip to content

Commit f82bc8a

Browse files
Allow JSONEncoder to return bytes directly (#11989) (#12115)
(cherry picked from commit 67fa1f5) --------- Co-authored-by: Kevin Park <[email protected]>
1 parent 9b63f4c commit f82bc8a

16 files changed

Lines changed: 396 additions & 10 deletions

CHANGES/11989.feature.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Added explicit APIs for bytes-returning JSON serializer:
2+
``JSONBytesEncoder`` type, ``JsonBytesPayload``,
3+
:func:`~aiohttp.web.json_bytes_response`,
4+
:meth:`~aiohttp.web.WebSocketResponse.send_json_bytes` and
5+
:meth:`~aiohttp.ClientWebSocketResponse.send_json_bytes` methods, and
6+
``json_serialize_bytes`` parameter for :class:`~aiohttp.ClientSession`
7+
-- by :user:`kevinpark1217`.

aiohttp/client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,14 @@
104104
from .http import WS_KEY, HttpVersion, WebSocketReader, WebSocketWriter
105105
from .http_websocket import WSHandshakeError, ws_ext_gen, ws_ext_parse
106106
from .tracing import Trace, TraceConfig
107-
from .typedefs import JSONEncoder, LooseCookies, LooseHeaders, Query, StrOrURL
107+
from .typedefs import (
108+
JSONBytesEncoder,
109+
JSONEncoder,
110+
LooseCookies,
111+
LooseHeaders,
112+
Query,
113+
StrOrURL,
114+
)
108115

109116
__all__ = (
110117
# client_exceptions
@@ -271,6 +278,7 @@ class ClientSession:
271278
"_default_auth",
272279
"_version",
273280
"_json_serialize",
281+
"_json_serialize_bytes",
274282
"_requote_redirect_url",
275283
"_timeout",
276284
"_raise_for_status",
@@ -311,6 +319,7 @@ def __init__(
311319
skip_auto_headers: Iterable[str] | None = None,
312320
auth: BasicAuth | None = None,
313321
json_serialize: JSONEncoder = json.dumps,
322+
json_serialize_bytes: JSONBytesEncoder | None = None,
314323
request_class: type[ClientRequest] = ClientRequest,
315324
response_class: type[ClientResponse] = ClientResponse,
316325
ws_response_class: type[ClientWebSocketResponse] = ClientWebSocketResponse,
@@ -421,6 +430,7 @@ def __init__(
421430
self._default_auth = auth
422431
self._version = version
423432
self._json_serialize = json_serialize
433+
self._json_serialize_bytes = json_serialize_bytes
424434
self._raise_for_status = raise_for_status
425435
self._auto_decompress = auto_decompress
426436
self._trust_env = trust_env
@@ -561,7 +571,10 @@ async def _request(
561571
"data and json parameters can not be used at the same time"
562572
)
563573
elif json is not None:
564-
data = payload.JsonPayload(json, dumps=self._json_serialize)
574+
if self._json_serialize_bytes is not None:
575+
data = payload.JsonBytesPayload(json, dumps=self._json_serialize_bytes)
576+
else:
577+
data = payload.JsonPayload(json, dumps=self._json_serialize)
565578

566579
if not isinstance(chunked, bool) and chunked is not None:
567580
warnings.warn("Chunk size is deprecated #1615", DeprecationWarning)

aiohttp/client_ws.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .typedefs import (
2828
DEFAULT_JSON_DECODER,
2929
DEFAULT_JSON_ENCODER,
30+
JSONBytesEncoder,
3031
JSONDecoder,
3132
JSONEncoder,
3233
)
@@ -303,6 +304,20 @@ async def send_json(
303304
) -> None:
304305
await self.send_str(dumps(data), compress=compress)
305306

307+
async def send_json_bytes(
308+
self,
309+
data: Any,
310+
compress: int | None = None,
311+
*,
312+
dumps: JSONBytesEncoder,
313+
) -> None:
314+
"""Send JSON data using a bytes-returning encoder as a binary frame.
315+
316+
Use this when your JSON encoder (like orjson) returns bytes
317+
instead of str, avoiding the encode/decode overhead.
318+
"""
319+
await self.send_bytes(dumps(data), compress=compress)
320+
306321
async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bool:
307322
# we need to break `receive()` cycle first,
308323
# `close()` may be called from different task

aiohttp/payload.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
sentinel,
2424
)
2525
from .streams import StreamReader
26-
from .typedefs import JSONEncoder, _CIMultiDict
26+
from .typedefs import JSONBytesEncoder, JSONEncoder, _CIMultiDict
2727

2828
__all__ = (
2929
"PAYLOAD_REGISTRY",
@@ -38,6 +38,7 @@
3838
"TextIOPayload",
3939
"StringIOPayload",
4040
"JsonPayload",
41+
"JsonBytesPayload",
4142
"AsyncIterablePayload",
4243
)
4344

@@ -943,6 +944,29 @@ def __init__(
943944
)
944945

945946

947+
class JsonBytesPayload(BytesPayload):
948+
"""JSON payload for encoders that return bytes directly.
949+
950+
Use this when your JSON encoder (like orjson) returns bytes
951+
instead of str, avoiding the encode/decode overhead.
952+
"""
953+
954+
def __init__(
955+
self,
956+
value: Any,
957+
dumps: JSONBytesEncoder,
958+
content_type: str = "application/json",
959+
*args: Any,
960+
**kwargs: Any,
961+
) -> None:
962+
super().__init__(
963+
dumps(value),
964+
content_type=content_type,
965+
*args,
966+
**kwargs,
967+
)
968+
969+
946970
if TYPE_CHECKING:
947971
from collections.abc import AsyncIterable, AsyncIterator
948972

aiohttp/typedefs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
Byteish = Union[bytes, bytearray, memoryview]
2929
JSONEncoder = Callable[[Any], str]
30+
JSONBytesEncoder = Callable[[Any], bytes]
3031
JSONDecoder = Callable[[str], Any]
3132
LooseHeaders = Union[
3233
Mapping[str, str],

aiohttp/web.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
ContentCoding as ContentCoding,
9797
Response as Response,
9898
StreamResponse as StreamResponse,
99+
json_bytes_response as json_bytes_response,
99100
json_response as json_response,
100101
)
101102
from .web_routedef import (
@@ -228,6 +229,7 @@
228229
"ContentCoding",
229230
"Response",
230231
"StreamResponse",
232+
"json_bytes_response",
231233
"json_response",
232234
"ResponseKey",
233235
# web_routedef

aiohttp/web_response.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,18 @@
3232
)
3333
from .http import SERVER_SOFTWARE, HttpVersion10, HttpVersion11
3434
from .payload import Payload
35-
from .typedefs import JSONEncoder, LooseHeaders
35+
from .typedefs import JSONBytesEncoder, JSONEncoder, LooseHeaders
3636

3737
REASON_PHRASES = {http_status.value: http_status.phrase for http_status in HTTPStatus}
3838
LARGE_BODY_SIZE = 1024**2
3939

40-
__all__ = ("ContentCoding", "StreamResponse", "Response", "json_response")
40+
__all__ = (
41+
"ContentCoding",
42+
"StreamResponse",
43+
"Response",
44+
"json_response",
45+
"json_bytes_response",
46+
)
4147

4248

4349
if TYPE_CHECKING:
@@ -878,3 +884,32 @@ def json_response(
878884
headers=headers,
879885
content_type=content_type,
880886
)
887+
888+
889+
def json_bytes_response(
890+
data: Any = sentinel,
891+
*,
892+
dumps: JSONBytesEncoder,
893+
body: bytes | None = None,
894+
status: int = 200,
895+
reason: str | None = None,
896+
headers: LooseHeaders | None = None,
897+
content_type: str = "application/json",
898+
) -> Response:
899+
"""Create a JSON response using a bytes-returning encoder.
900+
901+
Use this when your JSON encoder (like orjson) returns bytes
902+
instead of str, avoiding the encode/decode overhead.
903+
"""
904+
if data is not sentinel:
905+
if body is not None:
906+
raise ValueError("only one of data or body should be specified")
907+
else:
908+
body = dumps(data)
909+
return Response(
910+
body=body,
911+
status=status,
912+
reason=reason,
913+
headers=headers,
914+
content_type=content_type,
915+
)

aiohttp/web_ws.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from .http_websocket import _INTERNAL_RECEIVE_TYPES
3535
from .log import ws_logger
3636
from .streams import EofStream
37-
from .typedefs import JSONDecoder, JSONEncoder
37+
from .typedefs import JSONBytesEncoder, JSONDecoder, JSONEncoder
3838
from .web_exceptions import HTTPBadRequest, HTTPException
3939
from .web_request import BaseRequest
4040
from .web_response import StreamResponse
@@ -481,6 +481,20 @@ async def send_json(
481481
) -> None:
482482
await self.send_str(dumps(data), compress=compress)
483483

484+
async def send_json_bytes(
485+
self,
486+
data: Any,
487+
compress: int | None = None,
488+
*,
489+
dumps: JSONBytesEncoder,
490+
) -> None:
491+
"""Send JSON data using a bytes-returning encoder as a binary frame.
492+
493+
Use this when your JSON encoder (like orjson) returns bytes
494+
instead of str, avoiding the encode/decode overhead.
495+
"""
496+
await self.send_bytes(dumps(data), compress=compress)
497+
484498
async def write_eof(self) -> None: # type: ignore[override]
485499
if self._eof_sent:
486500
return

docs/client_reference.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,6 +1850,28 @@ manually.
18501850
The method is converted into :term:`coroutine`,
18511851
*compress* parameter added.
18521852

1853+
.. method:: send_json_bytes(data, compress=None, *, dumps)
1854+
:async:
1855+
1856+
Send *data* to peer as a JSON binary frame using a bytes-returning encoder.
1857+
1858+
:param data: data to send.
1859+
1860+
:param int compress: sets specific level of compression for
1861+
single message,
1862+
``None`` for not overriding per-socket setting.
1863+
1864+
:param collections.abc.Callable dumps: any :term:`callable` that accepts an object and
1865+
returns JSON as :class:`bytes`
1866+
(e.g. ``orjson.dumps``).
1867+
1868+
:raise RuntimeError: if connection is not started or closing
1869+
1870+
:raise ValueError: if data is not serializable object
1871+
1872+
:raise TypeError: if value returned by ``dumps(data)`` is not
1873+
:class:`bytes`
1874+
18531875
.. method:: send_frame(message, opcode, compress=None)
18541876
:async:
18551877

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ nowait
227227
OAuth
228228
Online
229229
optimizations
230+
orjson
230231
os
231232
outcoming
232233
Overridable

0 commit comments

Comments
 (0)