How to report unknown json fields when using Kotlin lenient Json Serialisation

Viewed 391

My current project employs retrofit kotlin json serialisation.

I have configured Json as follows:-

Json { ignoreUnknownKeys = true isLenient = true }

Which allows my application to parse Json with unknown fields, however I wish to report

all unknown Json fields that I receive.

I cannot see any wahy to achieve this.

With Jackson faster xml Json library this is easily achieved, however I wish to use as few 3rd party libraries as possible.

Is there any way I can have lenient Json serialisation and capture all unknown Json fields?

2 Answers

Like the other answer, the code I will post in this answer constitutes more of a hack than a proper solution I would recommend, but unlike the other answer:

  1. It reports all unknown fields.
  2. Does not rely on exception handling logic (which I expect to increase runtime considerably and is a code smell).
  3. Might be appropriate in case the members of serializable objects where you want to allow/log unknown fields are limited.

That said, the real solution is likely a missing feature in the kotlinx.serialization library, and I encourage you to post a feature request on GitHub. The maintainers are very response and open to suggestions!

As you can see from the sources jccampanero linked to in comments, the right level of abstraction to introduce this behavior would be in StreamingJsonDecoder. A different solution than the one which follows would thus be to implement a custom format and copy/paste all Json code with a minor addition in StreamingJsonDecoder.handleUnknown. Again, not something I would recommend.

What follows may be appropriate in case (3) applies.

abstract class ReportUnknownFieldsSerializer<T : Any>(
    private val tSerializer: KSerializer<T>
) : JsonTransformingSerializer<T>( tSerializer )
{
    override fun transformDeserialize( element: JsonElement ): JsonElement
    {
        if ( element is JsonObject )
        {
            val expectedNames = tSerializer.descriptor.elementNames
            val actualNames = element.keys
            val unexpectedNames = actualNames.minus( expectedNames )

            println( unexpectedNames )
        }

        return element
    }
}

@Serializable
data class SomeObject( val expected: String )

object SomeObjectSerializer
    : ReportUnknownFieldsSerializer<SomeObject>( SomeObject.serializer() )

@Serializable
data class Wrapper(
    @Serializable( SomeObjectSerializer::class )
    val someObject: SomeObject
)


class ReportUnknownFieldsSerializerTest
{
    @Test
    fun unknown_fields_are_reported()
    {
        val json = Json { ignoreUnknownKeys = true; isLenient = true }

        val serialized = """{"someObject":{"expected":"normal","unexpected":"oh"}}"""

        // Outputs "[unexpected]"
        val wrapper: Wrapper = json.decodeFromString( serialized )
    }
}

A JsonTransformingSerializer is used which upon deserialization compares the expected fields names with the specified field names. For each serializable class member you want to enable this behavior you need to apply an instantiation of the base ReportUnknownFieldSerializer.

Thus, as stated before, this might not serve your actual use case. You wouldn't want to create a custom serializer for each member field type which you would then have to apply everywhere. If that is your use case, you likely want to request it as a feature on the JSON serialization format to the maintainers of kotlinx.serialization. It would likely involve an additional configuration option on the Json class, e.g., a lambda callback to deal with unknown fields in whichever way you fancy.

You may use the following workaround: wrap your Json format into another one, which will try to decode the input string in a strict way (with ignoreUnknownKeys = false), and if it fails, report unknown keys and fallback to original Json format.

@ExperimentalSerializationApi
class JsonLoggingUnknownFields(private val kotlinx: Json) : StringFormat by kotlinx {
    private val kotlinxStrict = Json(from = kotlinx) { ignoreUnknownKeys = false }

    override fun <T> decodeFromString(deserializer: DeserializationStrategy<T>, string: String): T {
        val json = kotlinx.parseToJsonElement(string)
        return try {
            kotlinxStrict.decodeFromJsonElement(deserializer, json)
        } catch (e: SerializationException) {
            val unknownKeysInTopLevelJsonObject = if (json is JsonObject) {
                val actualFields = json.keys
                val expectedFields = deserializer.descriptor.elementNames
                actualFields - expectedFields
            } else emptySet()

            log.debug(
                if (unknownKeysInTopLevelJsonObject.isNotEmpty()) "Encountered unknown keys: $unknownKeysInTopLevelJsonObject\nCurrent input: $string"
                else e.message!!.replace("Use 'ignoreUnknownKeys = true' in 'Json {}' builder to ignore unknown keys.\n", "")
            )

            kotlinx.decodeFromJsonElement(deserializer, json)
        }
    }
}

Note that this trick will report all unknown JSON fields only for the top-level JSON object, for nested JSON objects (or JSON arrays) only the first encountered unknown field will be reported (if there were no unknown fields in the top-level JSON object).

Usage:

@Serializable
data class Obj(val x: Int = 0)

@Serializable
data class Obj2(val obj: Obj)

@ExperimentalSerializationApi
fun main() {
    val kotlinx = Json {
        ignoreUnknownKeys = true
        isLenient = true
    }
    val kotlinxWithLogging = JsonLoggingUnknownFields(kotlinx)

    val input1 = "{\"unknownKey1\": 1,\"unknownKey2\": 1, \"x\" : 11}"
    println(kotlinxWithLogging.decodeFromString<Obj>(input1)) //Encountered unknown keys: [unknownKey1, unknownKey2]
    val input2 = "[{\"unknownKey1\": 1,\"unknownKey2\": 1}]"
    println(kotlinxWithLogging.decodeFromString<List<Obj>>(input2)) //Encountered unknown key 'unknownKey1'.
    val input3 = "{\"obj\": {\"unknownKey1\": 1,\"unknownKey2\": 1}}"
    println(kotlinxWithLogging.decodeFromString<Obj2>(input3)) //Encountered unknown key 'unknownKey1'.
}
Related