To affect the shape and contents of JSON output after serialization, or adapt input to deserialization, it is possible to write a custom serializer. However, it may not be convenient to carefully follow complex Encoder and Decoder calling conventions, especially for relatively small and easy tasks. For that purpose, kotlinx.serialization
library gives you API that can boil down the burden of implementing a custom serializer to a problem of manipulating a Json elements tree.
It is still strongly recommended to be acquainted with custom serializers guide in its using serializers part, since the instantiation process and annotations to enable custom serializers remain the same. It is also important to remember that since the discussed in this article serializers are manipulating Json tree, they work only with Json format.
Transformation capabilities are provided by the abstract JsonTransformingSerializer
class that implements KSerializer
.
Instead of direct interaction with Encoder or Decoder, this class asks you to supply transformations for JSON tree represented by the JsonElement
class using writeTransform(element: JsonElement): JsonElement
and readTransform(element: JsonElement): JsonElement
methods in order to transform the input or the putput JSON.
The first example is our own implementation of list wrapping. Consider the API that returns list of objects, or, if there is only one element in the result, then it is a single object, not wrapped in the list.
To simplify object model, it is possible to implement transforming serializer that always returns list of objects, automatically wrapping a single object in a list as well:
@Serializable // Sample class with data data class StringData(val data: String) object WrappingJsonListSerializer : JsonTransformingSerializer<List<StringData>>( StringData.serializer().list, "WrappingList" ) { // If response is not an array, then it is a single object that should be wrapped in the array override fun readTransform(element: JsonElement): JsonElement = if (element !is JsonArray) JsonArray(listOf(element)) else element } // This transformation does the opposite and can unwrap an element, if it is returned in an array. object UnwrappingJsonListSerializer : JsonTransformingSerializer<StringData>(StringData.serializer(), "UnwrappingList") { override fun readTransform(element: JsonElement): JsonElement { if (element !is JsonArray) return element require(element.size == 1) { "Array size must be equal to 1 to unwrap it automatically" } return element.first() } }
Now these serializers can be used as regular custom serializers and all transformations will be applied automatically:
// We can use these transformations as regular serializers: @Serializable data class Example( val name: String, @Serializable(UnwrappingJsonListSerializer::class) val data: StringData, @Serializable(WrappingJsonListSerializer::class) val moreData: List<StringData> ) // And for input {"name":"test","data":{"data":"str1"},"moreData": {"data":"str2"} } the corresponding Example("test", Data("str1"), listOf(Data("str2"))) will be deserialized.
Another example of a transformation is omitting specific values from the output JSON, e.g. because it is treated as default when missing or for any other domain-specific reasons.
// Serializer that removes "name: Second" key-value pair from resultuing JSON object DroppingNameSerializer : JsonTransformingSerializer<Example>(Example.serializer(), "DropName") { override fun writeTransform(element: JsonElement): JsonElement = // Filter top-level key value pair with key "name" with value equal to "Second" JsonObject(element.jsonObject.filterNot { (k, v) -> k == "name" && v.primitive.content == "Second" }) } // Result of such transformation is: json.stringify(DroppingNameSerializer, Example("First", StringData("str1"))) // => """{"name":"First","data":{"data":"str1"}}""" json.stringify(DroppingNameSerializer, Example("Second", StringData("str1"))) // => """{"data":{"data":"str1"}}"""
Typically, polymorphic serialization requires a dedicated "type"
property (also known as class discriminator) in the incoming JSON to determine actual serializer which can be used to deserialize Kotlin class.
However, sometimes (e.g. when interacting with external API) type property may not be present in the input, and it is expected to guess the actual type by the shape of JSON, for example by the presence of specific key.
JsonParametricSerializer
provides a skeleton implementation for such strategy. As with JsonTransformingSerializer
, JsonParametricSerializer
is a base class for custom serializers. It does not allow to override serialize
and deserialize
methods; instead, one should implement selectSerializer(element: JsonElement): KSerializer<out T>
method. The idea can be demonstrated by the following example:
interface Payment { val amount: String } @Serializable data class SuccessfulPayment(override val amount: String, val date: String) : Payment @Serializable data class RefundedPayment(override val amount: String, val date: String, val reason: String) : Payment object PaymentSerializer : JsonParametricSerializer<Payment>(Payment::class) { override fun selectSerializer(element: JsonElement): KSerializer<out Payment> = when { "reason" in element -> RefundedPayment.serializer() else -> SuccessfulPayment.serializer() } } // Both statements yield different subclasses of Payment: Json.parse(PaymentSerializer, """{"amount":"1.0","date":"03.02.2020"}""") Json.parse(PaymentSerializer, """{"amount":"2.0","date":"03.02.2020","reason":"complaint"}""")
You also can serialize data with such serializer. In that case, either registered or default serializer would be selected for the actual property type in runtime. No class discriminator would be added.
Although abstract serializers mentioned above can cover most of the cases, it is possible to implement similar machinery by ourselves, using only the KSerializer
. If one is not satisfied with abstract methods writeTransform
/readTransform
/selectSerializer
, altering serialize
/deserialize
is a way to go.
There are several hints on reading, working with and writing of JsonElement
:
Encoder
could be cast to JsonInput
, and Decoder
to JsonOutput
, if the current format is Json
.JsonInput
has method decodeJson(): JsonElement
, and JsonOutput
has method encodeJson(element: JsonElement)
which basically retrieve/insert an element from/to a current position in the stream.JsonInput
and JsonOutput
have json
property which returns Json
instance with all settings that are currently in use.Json
has methods fromJson(deserializer: DeserializationStrategy<T>, json: JsonElement): T
and toJson(serializer: SerializationStrategy<T>, value: T): JsonElement
.Given all that, it is possible to implement two-stage conversion Decoder -> JsonElement -> T
or T -> JsonElement -> Encoder
.
Typical usage would look like this:
// Class representing Either<Left|Right> sealed class Either { data class Left(val errorMsg: String) : Either() data class Right(val data: Payload) : Either() } // Serializer injects custom behavior by inspecting object content and writing object EitherSerializer : KSerializer<Either> { override val descriptor: SerialDescriptor = SerialDescriptor("mypackage.Either", UnionKind.SEALED) override fun deserialize(decoder: Decoder): Either { // Decoder -> JsonInput val input = decoder as? JsonInput ?: throw SerializationException("This class can be loaded only by Json") // JsonInput => JsonElement (JsonObject in this case) val tree = input.decodeJson() as? JsonObject ?: throw SerializationException("Expected JsonObject") if ("error" in tree) return Either.Left(tree.getPrimitive("error").content) // JsonElement -> object return Either.Right(input.json.decodeJson(tree, Payload.serializer())) } override fun serialize(encoder: Encoder, obj: Either) { // Encoder -> JsonOutput val output = encoder as? JsonOutput ?: throw SerializationException("This class can be saved only by Json") // object -> JsonElement val tree = when (obj) { is Either.Left -> JsonObject(mapOf("error" to JsonLiteral(obj.errorMsg))) is Either.Right -> output.json.toJson(obj.data, Payload.serializer()) } // JsonElement => JsonOutput output.encodeJson(tree) } }