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 output 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 toJsonInput
, andDecoder
toJsonOutput
, if the current format isJson
.JsonInput
has methoddecodeJson(): JsonElement
, andJsonOutput
has methodencodeJson(element: JsonElement)
which basically retrieve/insert an element from/to a current position in the stream.- Both
JsonInput
andJsonOutput
havejson
property which returnsJson
instance with all settings that are currently in use. Json
has methodsfromJson(deserializer: DeserializationStrategy<T>, json: JsonElement): T
andtoJson(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)
}
}