Skip to content

Commit 1090860

Browse files
committed
fix(http): make response.json() encode what the type declares
Two defects in the same expression. The bare Json drops every field whose value happens to equal its declared default, so fee=0 and status=0 vanish and the client cannot tell them from a missing field - a withdrawal rendered as NaN and a status of 'unknown' in production on 2026-07-26. Kotlin clients never see it because they restore defaults on decode, so this only ever breaks the JS side. The second one hid behind the first: the parameter is Any, so the reified serializer() resolves for Any and the branch threw SerializationException for every object. The only non-String caller is the rate limiter, which means hitting a rate limit returned a serialization error instead of the 429 body. Encode Map/List/primitives through JsonElement, and reject anything else with a message that says what to do instead.
1 parent b69960c commit 1090860

2 files changed

Lines changed: 162 additions & 3 deletions

File tree

‎neton-core/src/commonMain/kotlin/neton/core/http/HttpResponse.kt‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,59 @@
11
package neton.core.http
22

3+
import kotlinx.serialization.json.Json
4+
import kotlinx.serialization.json.JsonArray
5+
import kotlinx.serialization.json.JsonElement
6+
import kotlinx.serialization.json.JsonNull
37
import kotlinx.serialization.json.JsonObject
8+
import kotlinx.serialization.json.JsonPrimitive
9+
10+
/**
11+
* 响应体序列化 Json。
12+
*
13+
* `encodeDefaults = true` 是**契约要求**,不是可选项:kotlinx 的裸 `Json` 默认
14+
* `encodeDefaults = false`,会把任何「当前值恰好等于声明默认值」的字段**从响应
15+
* JSON 里整个删掉**。对客户端而言这与「这个字段不存在」无法区分——提现单
16+
* `fee = 0`、`status = 0(待审核)` 被丢掉后,H5 拿到 `undefined`,渲染出
17+
* `¥NaN.NaN` 和状态「未知」(2026-07-26 生产实测,一个根因炸三处)。
18+
*
19+
* **响应字段集必须由类型决定,不能由运行时取值决定。**
20+
*
21+
* 这一条 KSP 生成的路由(`ControllerProcessor.responseJson`)与两个适配器的
22+
* envelope 序列化都已遵守;本文件是同一条路径上最后一个还在用裸 `Json` 的出口。
23+
*
24+
* Kotlin 客户端感知不到这个 bug——它反序列化时会把缺失字段补回声明的默认值。
25+
* 只有 JS/TS 端才炸,所以它极容易在联调里被漏掉。
26+
*/
27+
private val responseJson = Json { encodeDefaults = true }
28+
29+
/**
30+
* 把 Map / List / 原始类型转成 [JsonElement]。
31+
*
32+
* 这里**不能**用 `Json.encodeToString(serializer(), data)`:`json()` 的形参声明成 `Any`,
33+
* reified 的 `serializer()` 就只能解析出 `Any` 的序列化器,运行期直接
34+
* `SerializationException`。也就是说这个分支此前对**任何**对象都是抛异常——
35+
* 包括限流拦截器传进来的那个 `mapOf("code" to 429, ...)`:触发限流时返回给客户端的
36+
* 不是 429 JSON,而是一个序列化异常。
37+
*
38+
* `@Serializable` 业务对象不走这条路:它们由 KSP 生成的路由预序列化成 `JsonContent`
39+
* (那条路径自带 `encodeDefaults = true`)。这里只兜底 Map / List / 原始类型,
40+
* 遇到别的类型显式报错,说清该怎么办——而不是留一个看不懂的序列化异常。
41+
*/
42+
private fun Any?.toResponseJsonElement(): JsonElement = when (this) {
43+
null -> JsonNull
44+
is JsonElement -> this
45+
is String -> JsonPrimitive(this)
46+
is Boolean -> JsonPrimitive(this)
47+
is Number -> JsonPrimitive(this)
48+
is Map<*, *> -> JsonObject(entries.associate { (k, v) -> k.toString() to v.toResponseJsonElement() })
49+
is Iterable<*> -> JsonArray(map { it.toResponseJsonElement() })
50+
is Array<*> -> JsonArray(map { it.toResponseJsonElement() })
51+
else -> throw IllegalArgumentException(
52+
"HttpResponse.json() only accepts a pre-encoded String, a JsonElement, or Map/List/primitives. " +
53+
"Got ${this::class.simpleName}. Serialize @Serializable types with a Json configured " +
54+
"with encodeDefaults = true (or return them from a controller and let KSP do it).",
55+
)
56+
}
457

558
/**
659
* 流式响应体写出器。由 [HttpResponse.stream] 提供,逐块写出响应体。
@@ -150,9 +203,7 @@ interface HttpResponse {
150203
val jsonString = when (data) {
151204
is String -> data
152205
is JsonObject -> data.toString()
153-
else -> kotlinx.serialization.json.Json.encodeToString(
154-
kotlinx.serialization.serializer(), data
155-
)
206+
else -> responseJson.encodeToString(JsonElement.serializer(), data.toResponseJsonElement())
156207
}
157208
write(jsonString.encodeToByteArray())
158209
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package neton.core.http
2+
3+
import kotlinx.coroutines.runBlocking
4+
import kotlinx.serialization.Serializable
5+
import kotlinx.serialization.json.Json
6+
import kotlin.test.Test
7+
import kotlin.test.assertContains
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertFailsWith
10+
11+
/**
12+
* 契约:**响应字段集由类型决定,不由运行时取值决定。**
13+
*
14+
* kotlinx 的裸 `Json` 默认 `encodeDefaults = false`,会把任何「当前值恰好等于声明默认值」
15+
* 的字段整个从响应 JSON 里删掉。客户端拿到的是「字段不存在」,与「字段值是 0」无从区分。
16+
*
17+
* 2026-07-26 生产实测:提现单 `fee = 0`、`status = 0(待审核)` 被丢掉,H5 渲染出
18+
* `¥NaN.NaN` 和状态「未知」;同一个根因还让后台的审批按钮永远不显示。
19+
*
20+
* 特别阴险的是 **Kotlin 客户端感知不到**——反序列化时缺失字段会被补回声明的默认值,
21+
* 于是 Kotlin↔Kotlin 的联调全绿,只有 JS/TS 端炸。所以这条必须由测试守着,
22+
* 不能指望联调发现。
23+
*/
24+
class ResponseEncodeDefaultsContractTest {
25+
26+
@Serializable
27+
private data class WithdrawalVO(
28+
val id: Long = 0,
29+
/** 手续费为 0 是最常见的正常值,不是「没有值」。 */
30+
val fee: Long = 0,
31+
/** 0 = 待审核,恰好是默认值,也恰好是后台最需要看到的那个状态。 */
32+
val status: Int = 0,
33+
val remark: String = "",
34+
)
35+
36+
private class RecordingResponse : HttpResponse {
37+
override var status: HttpStatus = HttpStatus.OK
38+
override val headers: MutableHeaders = TestHeaders()
39+
override val isCommitted: Boolean get() = body != null
40+
var body: String? = null
41+
override fun cookie(cookie: Cookie) {}
42+
override suspend fun write(data: ByteArray) { body = data.decodeToString() }
43+
}
44+
45+
/**
46+
* 业务 VO 走的是 KSP 生成的路由:预序列化成字符串再交给 `json()`。
47+
* 这条断言的是那份预序列化必须带 `encodeDefaults = true`——裸 `Json` 会把
48+
* 整个对象编成 `{}`。
49+
*/
50+
@Test
51+
fun fieldsEqualToTheirDefaultsSurviveSerialization() {
52+
val responseJson = Json { encodeDefaults = true }
53+
val body = responseJson.encodeToString(WithdrawalVO.serializer(), WithdrawalVO())
54+
55+
assertContains(body, "\"fee\"", message = "fee dropped -> client renders NaN: $body")
56+
assertContains(body, "\"status\"", message = "status dropped -> client shows 'unknown': $body")
57+
58+
// 反过来确认这条测试确实在测东西:裸 Json 会把它整个编空。
59+
assertEquals("{}", Json.encodeToString(WithdrawalVO.serializer(), WithdrawalVO()))
60+
}
61+
62+
/**
63+
* 限流拦截器传的就是这个形状。
64+
*
65+
* 修复前这里会抛 `SerializationException`——`json()` 的形参是 `Any`,reified 的
66+
* `serializer()` 只能解析出 `Any` 的序列化器。也就是说**触发限流时客户端拿到的
67+
* 不是 429 JSON,而是一个序列化异常**。
68+
*/
69+
@Test
70+
fun mapPayloadsAreEncodedInsteadOfThrowing() = runBlocking {
71+
val response = RecordingResponse()
72+
response.json(mapOf("code" to 429, "message" to "too many requests"))
73+
74+
val body = response.body ?: error("nothing was written")
75+
assertContains(body, "\"code\":429")
76+
assertContains(body, "\"message\":\"too many requests\"")
77+
}
78+
79+
@Test
80+
fun nestedListsAndNullsSurvive() = runBlocking {
81+
val response = RecordingResponse()
82+
response.json(mapOf("items" to listOf(1, 2), "next" to null))
83+
assertEquals("""{"items":[1,2],"next":null}""", response.body)
84+
}
85+
86+
/** 传字符串时按原样写出,不重新序列化(KSP 预序列化就走这条)。 */
87+
@Test
88+
fun preEncodedStringsPassThroughUnchanged() = runBlocking {
89+
val response = RecordingResponse()
90+
response.json("""{"already":"encoded"}""")
91+
assertEquals("""{"already":"encoded"}""", response.body)
92+
}
93+
94+
/**
95+
* 不认识的类型必须显式报错,说清该怎么办。
96+
*
97+
* 留一个看不懂的 `SerializationException` 才是最坏的:调用方会以为是数据问题,
98+
* 而真正的原因是这个入口本来就不接受任意对象。
99+
*/
100+
@Test
101+
fun unsupportedTypesFailLoudlyWithAnActionableMessage() = runBlocking {
102+
val response = RecordingResponse()
103+
val error = assertFailsWith<IllegalArgumentException> {
104+
response.json(WithdrawalVO())
105+
}
106+
assertContains(error.message ?: "", "encodeDefaults")
107+
}
108+
}

0 commit comments

Comments
 (0)