Skip to content

Commit fb02e66

Browse files
authored
Incorporate JsonPath into exception messages (#1841)
Fixes #1817 Fixes #1137
1 parent a46299e commit fb02e66

File tree

12 files changed

+427
-39
lines changed

12 files changed

+427
-39
lines changed

core/api/kotlinx-serialization-core.api

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ public abstract class kotlinx/serialization/encoding/AbstractDecoder : kotlinx/s
339339
public fun decodeFloat ()F
340340
public final fun decodeFloatElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)F
341341
public fun decodeInline (Lkotlinx/serialization/descriptors/SerialDescriptor;)Lkotlinx/serialization/encoding/Decoder;
342-
public final fun decodeInlineElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)Lkotlinx/serialization/encoding/Decoder;
342+
public fun decodeInlineElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)Lkotlinx/serialization/encoding/Decoder;
343343
public fun decodeInt ()I
344344
public final fun decodeIntElement (Lkotlinx/serialization/descriptors/SerialDescriptor;I)I
345345
public fun decodeLong ()J
@@ -349,7 +349,7 @@ public abstract class kotlinx/serialization/encoding/AbstractDecoder : kotlinx/s
349349
public final fun decodeNullableSerializableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;)Ljava/lang/Object;
350350
public fun decodeNullableSerializableValue (Lkotlinx/serialization/DeserializationStrategy;)Ljava/lang/Object;
351351
public fun decodeSequentially ()Z
352-
public final fun decodeSerializableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;)Ljava/lang/Object;
352+
public fun decodeSerializableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;)Ljava/lang/Object;
353353
public fun decodeSerializableValue (Lkotlinx/serialization/DeserializationStrategy;)Ljava/lang/Object;
354354
public fun decodeSerializableValue (Lkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;)Ljava/lang/Object;
355355
public static synthetic fun decodeSerializableValue$default (Lkotlinx/serialization/encoding/AbstractDecoder;Lkotlinx/serialization/DeserializationStrategy;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object;

core/commonMain/src/kotlinx/serialization/encoding/AbstractDecoder.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ public abstract class AbstractDecoder : Decoder, CompositeDecoder {
5757
final override fun decodeCharElement(descriptor: SerialDescriptor, index: Int): Char = decodeChar()
5858
final override fun decodeStringElement(descriptor: SerialDescriptor, index: Int): String = decodeString()
5959

60-
final override fun decodeInlineElement(
60+
override fun decodeInlineElement(
6161
descriptor: SerialDescriptor,
6262
index: Int
6363
): Decoder = decodeInline(descriptor.getElementDescriptor(index))
6464

65-
final override fun <T> decodeSerializableElement(
65+
override fun <T> decodeSerializableElement(
6666
descriptor: SerialDescriptor,
6767
index: Int,
6868
deserializer: DeserializationStrategy<T>,

docs/basic-serialization.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ fun main() {
297297
It produces the exception:
298298

299299
```text
300-
Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses04.Project', but it was missing
300+
Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses04.Project', but it was missing at path: $
301301
```
302302

303303
<!--- TEST LINES_START -->
@@ -383,7 +383,7 @@ fun main() {
383383
We get the following exception.
384384

385385
```text
386-
Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses07.Project', but it was missing
386+
Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses07.Project', but it was missing at path: $
387387
```
388388

389389
<!--- TEST LINES_START -->
@@ -411,7 +411,7 @@ Attempts to explicitly specify its value in the serial format, even if the speci
411411
value is equal to the default one, produces the following exception.
412412

413413
```text
414-
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 42: Encountered an unknown key 'language'.
414+
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 42: Encountered an unknown key 'language' at path: $.name
415415
Use 'ignoreUnknownKeys = true' in 'Json {}' builder to ignore unknown keys.
416416
```
417417

@@ -493,7 +493,7 @@ Even though the `language` property has a default value, it is still an error to
493493
the `null` value to it.
494494

495495
```text
496-
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 52: Expected string literal but 'null' literal was found.
496+
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 52: Expected string literal but 'null' literal was found at path: $.language
497497
Use 'coerceInputValues = true' in 'Json {}` builder to coerce nulls to default values.
498498
```
499499

formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonExceptions.kt

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,29 @@ internal fun InvalidFloatingPointEncoded(value: Number, output: String) = JsonEn
3838
"Current output: ${output.minify()}"
3939
)
4040

41-
internal fun InvalidFloatingPointEncoded(value: Number, key: String, output: String) =
42-
JsonEncodingException(unexpectedFpErrorMessage(value, key, output))
43-
44-
internal fun InvalidFloatingPointDecoded(value: Number, key: String, output: String) =
45-
JsonDecodingException(-1, unexpectedFpErrorMessage(value, key, output))
4641

4742
// Extension on JSON reader and fail immediately
4843
internal fun AbstractJsonLexer.throwInvalidFloatingPointDecoded(result: Number): Nothing {
4944
fail("Unexpected special floating-point value $result. By default, " +
50-
"non-finite floating point values are prohibited because they do not conform JSON specification. " +
51-
specialFlowingValuesHint
52-
)
45+
"non-finite floating point values are prohibited because they do not conform JSON specification",
46+
hint = specialFlowingValuesHint)
5347
}
5448

49+
@OptIn(ExperimentalSerializationApi::class)
50+
internal fun InvalidKeyKindException(keyDescriptor: SerialDescriptor) = JsonEncodingException(
51+
"Value of type '${keyDescriptor.serialName}' can't be used in JSON as a key in the map. " +
52+
"It should have either primitive or enum kind, but its kind is '${keyDescriptor.kind}'.\n" +
53+
allowStructuredMapKeysHint
54+
)
55+
56+
// Exceptions for tree-based decoder
57+
58+
internal fun InvalidFloatingPointEncoded(value: Number, key: String, output: String) =
59+
JsonEncodingException(unexpectedFpErrorMessage(value, key, output))
60+
61+
internal fun InvalidFloatingPointDecoded(value: Number, key: String, output: String) =
62+
JsonDecodingException(-1, unexpectedFpErrorMessage(value, key, output))
63+
5564
private fun unexpectedFpErrorMessage(value: Number, key: String, output: String): String {
5665
return "Unexpected special floating-point value $value with key $key. By default, " +
5766
"non-finite floating point values are prohibited because they do not conform JSON specification. " +
@@ -66,13 +75,6 @@ internal fun UnknownKeyException(key: String, input: String) = JsonDecodingExcep
6675
"Current input: ${input.minify()}"
6776
)
6877

69-
@OptIn(ExperimentalSerializationApi::class)
70-
internal fun InvalidKeyKindException(keyDescriptor: SerialDescriptor) = JsonEncodingException(
71-
"Value of type '${keyDescriptor.serialName}' can't be used in JSON as a key in the map. " +
72-
"It should have either primitive or enum kind, but its kind is '${keyDescriptor.kind}'.\n" +
73-
allowStructuredMapKeysHint
74-
)
75-
7678
private fun CharSequence.minify(offset: Int = -1): CharSequence {
7779
if (length < 200) return this
7880
if (offset == -1) {

formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ internal fun SerialDescriptor.getJsonNameIndex(json: Json, name: String): Int {
5757
* Throws on [CompositeDecoder.UNKNOWN_NAME]
5858
*/
5959
@OptIn(ExperimentalSerializationApi::class)
60-
internal fun SerialDescriptor.getJsonNameIndexOrThrow(json: Json, name: String): Int {
60+
internal fun SerialDescriptor.getJsonNameIndexOrThrow(json: Json, name: String, suffix: String = ""): Int {
6161
val index = getJsonNameIndex(json, name)
6262
if (index == CompositeDecoder.UNKNOWN_NAME)
63-
throw SerializationException("$serialName does not contain element with name '$name'")
63+
throw SerializationException("$serialName does not contain element with name '$name'$suffix")
6464
return index
6565
}
6666

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package kotlinx.serialization.json.internal
2+
3+
import kotlinx.serialization.*
4+
import kotlinx.serialization.descriptors.*
5+
import kotlinx.serialization.internal.*
6+
7+
/**
8+
* Internal representation of the current JSON path.
9+
* It is stored as the array of serial descriptors (for regular classes)
10+
* and `Any?` in case of Map keys.
11+
*
12+
* Example of the state when decoding the list
13+
* ```
14+
* class Foo(val a: Int, val l: List<String>)
15+
*
16+
* // {"l": ["a", "b", "c"] }
17+
*
18+
* Current path when decoding array elements:
19+
* Foo.descriptor, List(String).descriptor
20+
* 1 (index of the 'l'), 2 (index of currently being decoded "c")
21+
* ```
22+
*/
23+
internal class JsonPath {
24+
25+
// Tombstone indicates that we are within a map, but the map key is currently being decoded.
26+
// It is also used to overwrite a previous map key to avoid memory leaks and misattribution.
27+
object Tombstone
28+
29+
/*
30+
* Serial descriptor, map key or the tombstone for map key
31+
*/
32+
private var currentObjectPath = arrayOfNulls<Any?>(8)
33+
/*
34+
* Index is a small state-machine used to determine the state of the path:
35+
* >=0 -> index of the element being decoded with the outer class currentObjectPath[currentDepth]
36+
* -1 -> nested elements are not yet decoded
37+
* -2 -> the map is being decoded and both its descriptor AND the last key were added to the path.
38+
*
39+
* -2 is effectively required to specify that two slots has been claimed and both should be
40+
* cleaned up when the decoding is done.
41+
* The cleanup is essential in order to avoid memory leaks for huge strings and structured keys.
42+
*/
43+
private var indicies = IntArray(8) { -1 }
44+
private var currentDepth = -1
45+
46+
// Invoked when class is started being decoded
47+
fun pushDescriptor(sd: SerialDescriptor) {
48+
val depth = ++currentDepth
49+
if (depth == currentObjectPath.size) {
50+
resize()
51+
}
52+
currentObjectPath[depth] = sd
53+
}
54+
55+
// Invoked when index-th element of the current descriptor is being decoded
56+
fun updateDescriptorIndex(index: Int) {
57+
indicies[currentDepth] = index
58+
}
59+
60+
/*
61+
* For maps we cannot use indicies and should use the key as an element of the path instead.
62+
* The key can be even an object (e.g. in a case of 'allowStructuredMapKeys') where
63+
* 'toString' is way too heavy or have side-effects.
64+
* For that we are storing the key instead.
65+
*/
66+
fun updateCurrentMapKey(key: Any?) {
67+
// idx != -2 -> this is the very first key being added
68+
if (indicies[currentDepth] != -2 && ++currentDepth == currentObjectPath.size) {
69+
resize()
70+
}
71+
currentObjectPath[currentDepth] = key
72+
indicies[currentDepth] = -2
73+
}
74+
75+
/** Used to indicate that we are in the process of decoding the key itself and can't specify it in path */
76+
fun resetCurrentMapKey() {
77+
if (indicies[currentDepth] == -2) {
78+
currentObjectPath[currentDepth] = Tombstone
79+
}
80+
}
81+
82+
fun popDescriptor() {
83+
// When we are ending map, we pop the last key and the outer field as well
84+
val depth = currentDepth
85+
if (indicies[depth] == -2) {
86+
indicies[depth] = -1
87+
currentDepth--
88+
}
89+
// Guard against top-level maps
90+
if (currentDepth != -1) {
91+
// No need to clean idx up as it was already cleaned by updateDescriptorIndex(DECODE_DONE)
92+
currentDepth--
93+
}
94+
}
95+
96+
@OptIn(ExperimentalSerializationApi::class)
97+
fun getPath(): String {
98+
return buildString {
99+
append("$")
100+
repeat(currentDepth + 1) {
101+
val element = currentObjectPath[it]
102+
if (element is SerialDescriptor) {
103+
if (element.kind == StructureKind.LIST) {
104+
if (indicies[it] != -1) {
105+
append("[")
106+
append(indicies[it])
107+
append("]")
108+
}
109+
} else {
110+
val idx = indicies[it]
111+
// If an actual element is being decoded
112+
if (idx >= 0) {
113+
append(".")
114+
append(element.getElementName(idx))
115+
}
116+
}
117+
} else if (element !== Tombstone) {
118+
append("[")
119+
// All non-indicies should be properly quoted by JsonPath convention
120+
append("'")
121+
// Else -- map key
122+
append(element)
123+
append("'")
124+
append("]")
125+
}
126+
}
127+
}
128+
}
129+
130+
131+
@OptIn(ExperimentalSerializationApi::class)
132+
private fun prettyString(it: Any?) = (it as? SerialDescriptor)?.serialName ?: it.toString()
133+
134+
private fun resize() {
135+
val newSize = currentDepth * 2
136+
currentObjectPath = currentObjectPath.copyOf(newSize)
137+
indicies = indicies.copyOf(newSize)
138+
}
139+
140+
override fun toString(): String = getPath()
141+
}

formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonDecoder.kt

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,18 @@ internal open class StreamingJsonDecoder(
3232

3333
override fun decodeJsonElement(): JsonElement = JsonTreeReader(json.configuration, lexer).read()
3434

35+
@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
3536
override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T {
36-
return decodeSerializableValuePolymorphic(deserializer)
37+
try {
38+
return decodeSerializableValuePolymorphic(deserializer)
39+
} catch (e: MissingFieldException) {
40+
throw MissingFieldException(e.message + " at path: " + lexer.path.getPath(), e)
41+
}
3742
}
3843

3944
override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder {
4045
val newMode = json.switchMode(descriptor)
46+
lexer.path.pushDescriptor(descriptor)
4147
lexer.consumeNextToken(newMode.begin)
4248
checkLeadingComma()
4349
return when (newMode) {
@@ -63,7 +69,10 @@ internal open class StreamingJsonDecoder(
6369
if (json.configuration.ignoreUnknownKeys && descriptor.elementsCount == 0) {
6470
skipLeftoverElements(descriptor)
6571
}
72+
// First consume the object so we know it's correct
6673
lexer.consumeNextToken(mode.end)
74+
// Then cleanup the path
75+
lexer.path.popDescriptor()
6776
}
6877

6978
private fun skipLeftoverElements(descriptor: SerialDescriptor) {
@@ -87,12 +96,37 @@ internal open class StreamingJsonDecoder(
8796
}
8897
}
8998

99+
override fun <T> decodeSerializableElement(
100+
descriptor: SerialDescriptor,
101+
index: Int,
102+
deserializer: DeserializationStrategy<T>,
103+
previousValue: T?
104+
): T {
105+
val isMapKey = mode == WriteMode.MAP && index and 1 == 0
106+
// Reset previous key
107+
if (isMapKey) {
108+
lexer.path.resetCurrentMapKey()
109+
}
110+
// Deserialize the key
111+
val value = super.decodeSerializableElement(descriptor, index, deserializer, previousValue)
112+
// Put the key to the path
113+
if (isMapKey) {
114+
lexer.path.updateCurrentMapKey(value)
115+
}
116+
return value
117+
}
118+
90119
override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
91-
return when (mode) {
120+
val index = when (mode) {
92121
WriteMode.OBJ -> decodeObjectIndex(descriptor)
93122
WriteMode.MAP -> decodeMapIndex()
94123
else -> decodeListIndex() // Both for LIST and default polymorphic
95124
}
125+
// The element of the next index that will be decoded
126+
if (mode != WriteMode.MAP) {
127+
lexer.path.updateDescriptorIndex(index)
128+
}
129+
return index
96130
}
97131

98132
private fun decodeMapIndex(): Int {
@@ -162,6 +196,8 @@ internal open class StreamingJsonDecoder(
162196
if (configuration.ignoreUnknownKeys) {
163197
lexer.skipElement(configuration.isLenient)
164198
} else {
199+
// Here we cannot properly update json path indicies
200+
// as we do not have a proper SerialDecriptor in our hands
165201
lexer.failOnUnknownKey(key)
166202
}
167203
return lexer.tryConsumeComma()
@@ -262,7 +298,7 @@ internal open class StreamingJsonDecoder(
262298
else super.decodeInline(inlineDescriptor)
263299

264300
override fun decodeEnum(enumDescriptor: SerialDescriptor): Int {
265-
return enumDescriptor.getJsonNameIndexOrThrow(json, decodeString())
301+
return enumDescriptor.getJsonNameIndexOrThrow(json, decodeString(), " at path " + lexer.path.getPath())
266302
}
267303
}
268304

0 commit comments

Comments
 (0)