Skip to content

Commit eabc500

Browse files
committed
Release v0.3.2: compose multiple FastPayloads in one Response
FastJSONRenderer now handles dict responses whose values mix FastPayload markers with arbitrary Python. Each FastPayload encodes via pydantic-core (Rust), each plain value via DRF's stock JSONEncoder. Walks data in insertion order so output key order matches input. Keys are escaped via json.dumps so quotes and backslashes in user-supplied keys still produce valid JSON. Unblocks bundle-style endpoints (e.g. a single worksheet response returning header + transactions + balances + summary, four separate FastSerializer schemas) without manual byte splicing, slot-level access to FastPayload internals, or model_dump mode juggling that loses the Rust render path entirely. Pagination envelopes flow through the same composed path; the dedicated _render_paginated helper is gone, behavior is unchanged, regression test added. Indented output (indent= in renderer_context, or non-compact JSON setting) falls back to materializing every FastPayload and routing through super().render() since pydantic-core's dump_json is compact-only and join bytes wouldn't have indentation structure. Debug-mode only; hot paths stay on the fast path. Known limit: only top-level dict values are inspected. A FastPayload nested inside a list or sub-dict still falls through to stock and raises. Lift the inner payload up a level, or encode manually.
1 parent e9d6058 commit eabc500

6 files changed

Lines changed: 211 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
## [0.3.2] - 2026-05-25
11+
12+
### Added
13+
- `FastJSONRenderer` now composes dict responses whose values are a mix
14+
of `FastPayload` markers and arbitrary Python. Each `FastPayload` is
15+
encoded via pydantic-core (Rust); each plain value is encoded with
16+
DRF's stock `JSONEncoder`. Keys are emitted in input insertion order
17+
and escaped via `json.dumps`. Unlocks bundle-style endpoints
18+
(multiple `Schema.drf(...).data` values in one `Response({...})`)
19+
without manual byte splicing or mode-juggling on `model_dump`.
20+
- Pagination envelopes (`{"results": FastPayload, "count": int, ...}`)
21+
flow through the same composed path. The dedicated
22+
`_render_paginated` helper is gone; pagination behavior is unchanged
23+
and covered by both `test_pagination.py` and a new explicit
24+
regression in `test_renderer.py`.
25+
26+
### Changed
27+
- Composed responses fall back to materialize-via-`super().render()`
28+
when `renderer_context` requests indented output or DRF is set to
29+
non-compact JSON, since the Rust `dump_json` path can't pretty-print.
30+
Pretty output is debug-only and rarely on the hot path.
31+
32+
### Known limitation
33+
- Only top-level `FastPayload` values in a dict are picked up.
34+
`FastPayload` nested inside a list or sub-dict still falls through to
35+
stock encoding and raises (no in-place encoder for sub-trees). If
36+
this comes up in practice, lift the inner payload to a `Schema.drf`
37+
one level up, or encode it manually.
38+
1039
## [0.3.1] - 2026-05-25
1140

1241
### Added

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,33 @@ encoding for error responses, hand-rolled dicts, the browsable API, and
135135
anything else it doesn't recognize. Safe as a project-wide default. Set
136136
`renderer_classes` per view if you want to roll it out gradually.
137137

138+
### Composed responses
139+
140+
Bundle-style endpoints often return multiple serialized payloads under
141+
one dict. The renderer handles that natively — every value that is a
142+
`FastPayload` goes through pydantic-core (Rust), everything else
143+
through DRF's stock `JSONEncoder`. Key order is preserved.
144+
145+
```python
146+
class WorksheetBundleView(APIView):
147+
renderer_classes = [FastJSONRenderer]
148+
149+
def get(self, request, uid):
150+
ws = Worksheet.objects.get(uid=uid)
151+
return Response({
152+
"worksheet": FastWorksheetHeader.drf(instance=ws).data,
153+
"transactions": BundleTxnRow.drf(instance=ws.transactions.all(), many=True).data,
154+
"balances": BundleBalanceRow.drf(instance=ws.balances.all(), many=True).data,
155+
"summary_rows": BundleSummaryRow.drf(instance=ws.summary_rows.all(), many=True).data,
156+
"meta": {"as_of": now()}, # plain value: stock JSON encoder
157+
})
158+
```
159+
160+
Pagination envelopes flow through the same path with no special
161+
configuration. Indented output (`?format=json&indent=2`) falls back to
162+
a materialized render — the Rust encoder is compact-only — so leaving
163+
`COMPACT_JSON` at its default is what unlocks the speedup.
164+
138165
### SerializerMethodField
139166

140167
Auto-translated. The bound `get_*` method runs once per row at validate

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "drf-fastserializers"
3-
version = "0.3.1"
3+
version = "0.3.2"
44
description = "Drop-in pydantic-powered serializers for Django REST Framework. 2-3x faster on large payloads."
55
readme = "README.md"
66
license = "MIT"

src/drf_fastserializers/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,4 @@ class TxnListView(ListAPIView):
7979
"PYD_V3",
8080
]
8181

82-
__version__ = "0.3.1"
82+
__version__ = "0.3.2"

src/drf_fastserializers/renderer.py

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ class MyView(ListAPIView):
66
renderer_classes = [FastJSONRenderer]
77
serializer_class = TxnOut.drf
88
9-
Falls back to the stock `JSONRenderer` for any non-`FastPayload` payload
10-
(error responses, browsable API previews, hand-rolled dicts, etc.), so
11-
it is safe as a global default renderer in `REST_FRAMEWORK` settings.
9+
Three input shapes are handled directly:
10+
11+
- A bare `FastPayload` (most views) → `adapter.dump_json` (Rust).
12+
- A dict with any `FastPayload` values (composed bundles, pagination
13+
envelopes, etc.) → per-value walk; FastPayloads encode via Rust,
14+
everything else via DRF's stock `JSONEncoder`.
15+
- Anything else (error responses, hand-rolled dicts, the browsable API,
16+
...) → super().render(), unchanged.
17+
18+
The renderer is safe as a project-wide default.
1219
"""
1320

21+
import json
1422
from typing import Any
1523

1624
from rest_framework.renderers import JSONRenderer
@@ -30,32 +38,56 @@ def render(
3038
if isinstance(data, FastPayload):
3139
return data.adapter.dump_json(data.instances)
3240

33-
if isinstance(data, dict) and isinstance(data.get("results"), FastPayload):
34-
return self._render_paginated(data, accepted_media_type, renderer_context)
41+
if isinstance(data, dict) and any(isinstance(v, FastPayload) for v in data.values()):
42+
return self._render_composed(data, accepted_media_type, renderer_context)
3543

3644
return super().render(data, accepted_media_type, renderer_context)
3745

38-
# DRF pagination wraps payloads as {"results": <FastPayload>, "next": ...}.
39-
# Encode the inner payload with Rust, encode the wrapper with stock JSON,
40-
# then splice the bytes. Cheap and correct: pagination metadata is small,
41-
# results are the bulk.
42-
def _render_paginated(
46+
# Compose dict responses whose values are a mix of `FastPayload` markers
47+
# (Rust-encoded) and arbitrary Python (encoded via DRF's JSONEncoder).
48+
# Walks `data` in insertion order so output key order matches input.
49+
# Pagination envelopes (`{"results": FastPayload, "count": int, ...}`)
50+
# are a subset of this case — no separate pagination path.
51+
def _render_composed(
4352
self,
4453
data: dict[str, Any],
4554
accepted_media_type: str | None,
4655
renderer_context: dict[str, Any] | None,
4756
) -> bytes:
48-
marker: FastPayload = data["results"]
49-
inner = marker.adapter.dump_json(marker.instances)
50-
wrapper = {k: v for k, v in data.items() if k != "results"}
51-
wrapper_bytes = super().render(wrapper, accepted_media_type, renderer_context)
52-
if not wrapper_bytes.endswith(b"}"):
53-
# super().render may emit indented form; fall back to full materialize
54-
return super().render(
55-
{**wrapper, "results": marker._materialize()},
56-
accepted_media_type,
57-
renderer_context,
58-
)
59-
head = wrapper_bytes[:-1]
60-
sep = b"," if wrapper else b""
61-
return head + sep + b'"results":' + inner + b"}"
57+
ctx = renderer_context or {}
58+
indent = self.get_indent(accepted_media_type, ctx)
59+
# Indented or non-compact output can't be assembled cleanly by
60+
# byte-splicing — pydantic-core's dump_json output is compact,
61+
# and join bytes would have no indentation. Materialize every
62+
# `FastPayload` and route through super() for a consistent
63+
# pretty-printed response. The fast path is the common case
64+
# (default DRF settings: compact=True, indent=None).
65+
if indent is not None or not self.compact:
66+
materialized = {
67+
k: v._materialize() if isinstance(v, FastPayload) else v
68+
for k, v in data.items()
69+
}
70+
return super().render(materialized, accepted_media_type, renderer_context)
71+
72+
parts: list[bytes] = [b"{"]
73+
for i, (k, v) in enumerate(data.items()):
74+
if i > 0:
75+
parts.append(b",")
76+
# json.dumps handles quote/backslash/control-char escaping in keys
77+
# so a user dict with `{'a"b': ...}` still produces valid JSON.
78+
parts.append(json.dumps(k, ensure_ascii=self.ensure_ascii).encode("utf-8"))
79+
parts.append(b":")
80+
if isinstance(v, FastPayload):
81+
parts.append(v.adapter.dump_json(v.instances))
82+
else:
83+
parts.append(
84+
json.dumps(
85+
v,
86+
cls=self.encoder_class,
87+
ensure_ascii=self.ensure_ascii,
88+
allow_nan=not self.strict,
89+
separators=(",", ":"),
90+
).encode("utf-8")
91+
)
92+
parts.append(b"}")
93+
return b"".join(parts)

tests/test_renderer.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,100 @@ def test_renderer_single_instance(txn_dict: dict):
4242
parsed = json.loads(raw)
4343
assert parsed["id"] == 1
4444
assert parsed["flags"]["is_refund"] is True
45+
46+
47+
# Composed envelope tests --------------------------------------------------
48+
49+
50+
def test_renderer_composes_mixed_dict(txn_dict: dict):
51+
"""Bundle-style response: multiple FastPayloads + plain values in one dict."""
52+
payload = {
53+
"header": TxnOut.drf(instance=txn_dict).data,
54+
"items": TxnOut.drf(instance=[txn_dict, {**txn_dict, "id": 2}], many=True).data,
55+
"meta": {"page": 1, "total": 2},
56+
}
57+
raw = FastJSONRenderer().render(payload)
58+
parsed = json.loads(raw)
59+
assert parsed["header"]["id"] == 1
60+
assert [r["id"] for r in parsed["items"]] == [1, 2]
61+
assert parsed["meta"] == {"page": 1, "total": 2}
62+
63+
64+
def test_renderer_composed_all_fastpayloads(txn_dict: dict):
65+
"""Envelope with no plain values: every key is a FastPayload."""
66+
payload = {
67+
"a": TxnOut.drf(instance=txn_dict).data,
68+
"b": TxnOut.drf(instance={**txn_dict, "id": 99}).data,
69+
}
70+
raw = FastJSONRenderer().render(payload)
71+
parsed = json.loads(raw)
72+
assert parsed["a"]["id"] == 1
73+
assert parsed["b"]["id"] == 99
74+
75+
76+
def test_renderer_composed_preserves_key_order(txn_dict: dict):
77+
"""Output key order matches input insertion order."""
78+
payload = {
79+
"z": {"plain": 1},
80+
"a": TxnOut.drf(instance=txn_dict).data,
81+
"m": {"plain": 2},
82+
"b": TxnOut.drf(instance={**txn_dict, "id": 9}).data,
83+
}
84+
raw = FastJSONRenderer().render(payload)
85+
# Walk the bytes for ordering; json.loads on a plain dict would
86+
# preserve order in 3.7+ but checking the raw stream is unambiguous.
87+
assert raw.index(b'"z"') < raw.index(b'"a"') < raw.index(b'"m"') < raw.index(b'"b"')
88+
89+
90+
def test_renderer_composed_handles_special_chars_in_keys(txn_dict: dict):
91+
"""Keys containing `"` or `\\` must still produce valid JSON."""
92+
payload = {
93+
'has"quote': TxnOut.drf(instance=txn_dict).data,
94+
"back\\slash": {"ok": True},
95+
}
96+
raw = FastJSONRenderer().render(payload)
97+
parsed = json.loads(raw)
98+
assert parsed['has"quote']["id"] == 1
99+
assert parsed["back\\slash"] == {"ok": True}
100+
101+
102+
def test_renderer_composed_pagination_envelope(txn_dicts: list[dict]):
103+
"""Pagination shape ({count, next, previous, results: FastPayload}) is
104+
handled by the composed path — no special-case code needed."""
105+
payload = {
106+
"count": 100,
107+
"next": "http://example.com/?page=2",
108+
"previous": None,
109+
"results": TxnOut.drf(instance=txn_dicts, many=True).data,
110+
}
111+
raw = FastJSONRenderer().render(payload)
112+
parsed = json.loads(raw)
113+
assert parsed["count"] == 100
114+
assert parsed["next"] == "http://example.com/?page=2"
115+
assert parsed["previous"] is None
116+
assert [r["id"] for r in parsed["results"]] == [d["id"] for d in txn_dicts]
117+
118+
119+
def test_renderer_composed_indented_falls_back_to_materialization(txn_dict: dict):
120+
"""indent=2 → Rust dump_json can't pretty-print; materialize all FastPayloads."""
121+
payload = {
122+
"header": TxnOut.drf(instance=txn_dict).data,
123+
"meta": {"page": 1},
124+
}
125+
raw = FastJSONRenderer().render(
126+
payload,
127+
accepted_media_type="application/json",
128+
renderer_context={"indent": 2},
129+
)
130+
parsed = json.loads(raw)
131+
assert parsed["header"]["id"] == 1
132+
assert parsed["meta"] == {"page": 1}
133+
# Indented output has newlines and spaces; the compact-path splice does not.
134+
assert b"\n" in raw or b": " in raw
135+
136+
137+
def test_renderer_error_envelope_passes_through():
138+
"""A plain error dict (no FastPayload values) should not trigger composed path."""
139+
raw = FastJSONRenderer().render({"detail": "not found", "code": 404})
140+
parsed = json.loads(raw)
141+
assert parsed == {"detail": "not found", "code": 404}

0 commit comments

Comments
 (0)