diff --git a/core/api/kotlinx-serialization-core.api b/core/api/kotlinx-serialization-core.api index 1b15d3595..8dad86376 100644 --- a/core/api/kotlinx-serialization-core.api +++ b/core/api/kotlinx-serialization-core.api @@ -424,6 +424,7 @@ public abstract class kotlinx/serialization/encoding/AbstractEncoder : kotlinx/s public final fun encodeLongElement (Lkotlinx/serialization/descriptors/SerialDescriptor;IJ)V public fun encodeNotNullMark ()V public fun encodeNull ()V + public fun encodeNullableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;IZ)Z public fun encodeNullableSerializableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/SerializationStrategy;Ljava/lang/Object;)V public fun encodeNullableSerializableValue (Lkotlinx/serialization/SerializationStrategy;Ljava/lang/Object;)V public fun encodeSerializableElement (Lkotlinx/serialization/descriptors/SerialDescriptor;ILkotlinx/serialization/SerializationStrategy;Ljava/lang/Object;)V diff --git a/core/api/kotlinx-serialization-core.klib.api b/core/api/kotlinx-serialization-core.klib.api index 5aefad0f2..57fc14443 100644 --- a/core/api/kotlinx-serialization-core.klib.api +++ b/core/api/kotlinx-serialization-core.klib.api @@ -498,6 +498,7 @@ abstract class kotlinx.serialization.encoding/AbstractEncoder : kotlinx.serializ open fun encodeInt(kotlin/Int) // kotlinx.serialization.encoding/AbstractEncoder.encodeInt|encodeInt(kotlin.Int){}[0] open fun encodeLong(kotlin/Long) // kotlinx.serialization.encoding/AbstractEncoder.encodeLong|encodeLong(kotlin.Long){}[0] open fun encodeNull() // kotlinx.serialization.encoding/AbstractEncoder.encodeNull|encodeNull(){}[0] + open fun encodeNullableElement(kotlinx.serialization.descriptors/SerialDescriptor, kotlin/Int, kotlin/Boolean): kotlin/Boolean // kotlinx.serialization.encoding/AbstractEncoder.encodeNullableElement|encodeNullableElement(kotlinx.serialization.descriptors.SerialDescriptor;kotlin.Int;kotlin.Boolean){}[0] open fun encodeShort(kotlin/Short) // kotlinx.serialization.encoding/AbstractEncoder.encodeShort|encodeShort(kotlin.Short){}[0] open fun encodeString(kotlin/String) // kotlinx.serialization.encoding/AbstractEncoder.encodeString|encodeString(kotlin.String){}[0] open fun encodeValue(kotlin/Any) // kotlinx.serialization.encoding/AbstractEncoder.encodeValue|encodeValue(kotlin.Any){}[0] diff --git a/core/commonMain/src/kotlinx/serialization/encoding/AbstractEncoder.kt b/core/commonMain/src/kotlinx/serialization/encoding/AbstractEncoder.kt index 384cb8a05..12164ae71 100644 --- a/core/commonMain/src/kotlinx/serialization/encoding/AbstractEncoder.kt +++ b/core/commonMain/src/kotlinx/serialization/encoding/AbstractEncoder.kt @@ -30,6 +30,8 @@ public abstract class AbstractEncoder : Encoder, CompositeEncoder { */ public open fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean = true + public open fun encodeNullableElement(descriptor: SerialDescriptor, index: Int, isNull: Boolean): Boolean = encodeElement(descriptor, index) + /** * Invoked to encode a value when specialized `encode*` method was not overridden. */ @@ -86,7 +88,7 @@ public abstract class AbstractEncoder : Encoder, CompositeEncoder { serializer: SerializationStrategy, value: T? ) { - if (encodeElement(descriptor, index)) + if (encodeNullableElement(descriptor, index, value == null)) encodeNullableSerializableValue(serializer, value) } } diff --git a/formats/cbor/api/kotlinx-serialization-cbor.api b/formats/cbor/api/kotlinx-serialization-cbor.api index e1e37801f..b28e99a8f 100644 --- a/formats/cbor/api/kotlinx-serialization-cbor.api +++ b/formats/cbor/api/kotlinx-serialization-cbor.api @@ -34,6 +34,7 @@ public final class kotlinx/serialization/cbor/CborBuilder { public final fun getIgnoreUnknownKeys ()Z public final fun getPreferCborLabelsOverNames ()Z public final fun getSerializersModule ()Lkotlinx/serialization/modules/SerializersModule; + public final fun getUntaggedNullValueTags ()Z public final fun getUseDefiniteLengthEncoding ()Z public final fun getVerifyKeyTags ()Z public final fun getVerifyObjectTags ()Z @@ -46,6 +47,7 @@ public final class kotlinx/serialization/cbor/CborBuilder { public final fun setIgnoreUnknownKeys (Z)V public final fun setPreferCborLabelsOverNames (Z)V public final fun setSerializersModule (Lkotlinx/serialization/modules/SerializersModule;)V + public final fun setUntaggedNullValueTags (Z)V public final fun setUseDefiniteLengthEncoding (Z)V public final fun setVerifyKeyTags (Z)V public final fun setVerifyObjectTags (Z)V @@ -60,6 +62,7 @@ public final class kotlinx/serialization/cbor/CborConfiguration { public final fun getEncodeValueTags ()Z public final fun getIgnoreUnknownKeys ()Z public final fun getPreferCborLabelsOverNames ()Z + public final fun getUntaggedNullValueTags ()Z public final fun getUseDefiniteLengthEncoding ()Z public final fun getVerifyKeyTags ()Z public final fun getVerifyObjectTags ()Z diff --git a/formats/cbor/api/kotlinx-serialization-cbor.klib.api b/formats/cbor/api/kotlinx-serialization-cbor.klib.api index 2658e3586..173de0490 100644 --- a/formats/cbor/api/kotlinx-serialization-cbor.klib.api +++ b/formats/cbor/api/kotlinx-serialization-cbor.klib.api @@ -77,6 +77,9 @@ final class kotlinx.serialization.cbor/CborBuilder { // kotlinx.serialization.cb final var serializersModule // kotlinx.serialization.cbor/CborBuilder.serializersModule|{}serializersModule[0] final fun (): kotlinx.serialization.modules/SerializersModule // kotlinx.serialization.cbor/CborBuilder.serializersModule.|(){}[0] final fun (kotlinx.serialization.modules/SerializersModule) // kotlinx.serialization.cbor/CborBuilder.serializersModule.|(kotlinx.serialization.modules.SerializersModule){}[0] + final var untaggedNullValueTags // kotlinx.serialization.cbor/CborBuilder.untaggedNullValueTags|{}untaggedNullValueTags[0] + final fun (): kotlin/Boolean // kotlinx.serialization.cbor/CborBuilder.untaggedNullValueTags.|(){}[0] + final fun (kotlin/Boolean) // kotlinx.serialization.cbor/CborBuilder.untaggedNullValueTags.|(kotlin.Boolean){}[0] final var useDefiniteLengthEncoding // kotlinx.serialization.cbor/CborBuilder.useDefiniteLengthEncoding|{}useDefiniteLengthEncoding[0] final fun (): kotlin/Boolean // kotlinx.serialization.cbor/CborBuilder.useDefiniteLengthEncoding.|(){}[0] final fun (kotlin/Boolean) // kotlinx.serialization.cbor/CborBuilder.useDefiniteLengthEncoding.|(kotlin.Boolean){}[0] @@ -106,6 +109,8 @@ final class kotlinx.serialization.cbor/CborConfiguration { // kotlinx.serializat final fun (): kotlin/Boolean // kotlinx.serialization.cbor/CborConfiguration.ignoreUnknownKeys.|(){}[0] final val preferCborLabelsOverNames // kotlinx.serialization.cbor/CborConfiguration.preferCborLabelsOverNames|{}preferCborLabelsOverNames[0] final fun (): kotlin/Boolean // kotlinx.serialization.cbor/CborConfiguration.preferCborLabelsOverNames.|(){}[0] + final val untaggedNullValueTags // kotlinx.serialization.cbor/CborConfiguration.untaggedNullValueTags|{}untaggedNullValueTags[0] + final fun (): kotlin/Boolean // kotlinx.serialization.cbor/CborConfiguration.untaggedNullValueTags.|(){}[0] final val useDefiniteLengthEncoding // kotlinx.serialization.cbor/CborConfiguration.useDefiniteLengthEncoding|{}useDefiniteLengthEncoding[0] final fun (): kotlin/Boolean // kotlinx.serialization.cbor/CborConfiguration.useDefiniteLengthEncoding.|(){}[0] final val verifyKeyTags // kotlinx.serialization.cbor/CborConfiguration.verifyKeyTags|{}verifyKeyTags[0] diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt index 21293a923..73d334d70 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/Cbor.kt @@ -42,7 +42,8 @@ public sealed class Cbor( verifyObjectTags = false, useDefiniteLengthEncoding = false, preferCborLabelsOverNames = false, - alwaysUseByteString = false + alwaysUseByteString = false, + untaggedNullValueTags = false, ), EmptySerializersModule() ) { @@ -119,7 +120,8 @@ public fun Cbor(from: Cbor = Cbor, builderAction: CborBuilder.() -> Unit): Cbor builder.verifyObjectTags, builder.useDefiniteLengthEncoding, builder.preferCborLabelsOverNames, - builder.alwaysUseByteString), + builder.alwaysUseByteString, + builder.untaggedNullValueTags), builder.serializersModule ) } @@ -243,6 +245,14 @@ public class CborBuilder internal constructor(cbor: Cbor) { */ public var alwaysUseByteString: Boolean = cbor.configuration.alwaysUseByteString + /** + * Specifies whether [ValueTags] will be serialized for `null` values when [encodeValueTags] is enabled, and if + * such encodings pass validation when [verifyValueTags] is enabled. CBOR allows for untagged `null` values to + * reduce encoding size. + * See [RFC 8949 Tagging of Items](https://datatracker.ietf.org/doc/html/rfc8949#name-tagging-of-items) for more info. + */ + public var untaggedNullValueTags: Boolean = cbor.configuration.untaggedNullValueTags + /** * Module with contextual and polymorphic serializers to be used in the resulting [Cbor] instance. */ diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt index 3d88627f2..51e2d1ea6 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborConfiguration.kt @@ -103,12 +103,14 @@ public class CborConfiguration internal constructor( public val useDefiniteLengthEncoding: Boolean, public val preferCborLabelsOverNames: Boolean, public val alwaysUseByteString: Boolean, + public val untaggedNullValueTags: Boolean, ) { override fun toString(): String { return "CborConfiguration(encodeDefaults=$encodeDefaults, ignoreUnknownKeys=$ignoreUnknownKeys, " + "encodeKeyTags=$encodeKeyTags, encodeValueTags=$encodeValueTags, encodeObjectTags=$encodeObjectTags, " + "verifyKeyTags=$verifyKeyTags, verifyValueTags=$verifyValueTags, verifyObjectTags=$verifyObjectTags, " + "useDefiniteLengthEncoding=$useDefiniteLengthEncoding, " + - "preferCborLabelsOverNames=$preferCborLabelsOverNames, alwaysUseByteString=$alwaysUseByteString)" + "preferCborLabelsOverNames=$preferCborLabelsOverNames, alwaysUseByteString=$alwaysUseByteString, " + + "untaggedNullValueTags=$untaggedNullValueTags)" } } \ No newline at end of file diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt index 88075db26..49ac282b6 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Decoder.kt @@ -135,7 +135,7 @@ internal open class CborReader(override val cbor: Cbor, protected val parser: Cb override fun decodeInt() = parser.nextNumber(tags).toInt() override fun decodeLong() = parser.nextNumber(tags) - override fun decodeNull() = parser.nextNull(tags) + override fun decodeNull() = parser.nextNull(tags, allowNoTags = cbor.configuration.untaggedNullValueTags) override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = enumDescriptor.getElementIndexOrThrow(parser.nextString(tags)) @@ -172,8 +172,8 @@ internal class CborParser(private val input: ByteArrayInput, private val verifyO fun isNull() = (curByte == NULL || curByte == EMPTY_MAP) - fun nextNull(tags: ULongArray? = null): Nothing? { - processTags(tags) + fun nextNull(tags: ULongArray? = null, allowNoTags: Boolean): Nothing? { + processTags(tags, allowNoTags) if (curByte == NULL) { skipByte(NULL) } else if (curByte == EMPTY_MAP) { @@ -250,7 +250,7 @@ internal class CborParser(private val input: ByteArrayInput, private val verifyO input.readExactNBytes(strLen) } - private fun processTags(tags: ULongArray?): ULongArray? { + private fun processTags(tags: ULongArray?, allowNoTags: Boolean = false): ULongArray? { var index = 0 val collectedTags = mutableListOf() while ((curByte and 0b111_00000) == HEADER_TAG) { @@ -265,6 +265,9 @@ internal class CborParser(private val input: ByteArrayInput, private val verifyO } readByte() } + if (collectedTags.isEmpty() && allowNoTags) { + return null + } return (if (collectedTags.isEmpty()) null else collectedTags.toULongArray()).also { collected -> //We only want to compare if tags are actually set, otherwise, we don't care tags?.let { diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt index eb5fc556a..197ccec9f 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt @@ -116,6 +116,11 @@ internal sealed class CborWriter( } override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { + return encodeNullableElement(descriptor, index, false) + } + + + override fun encodeNullableElement(descriptor: SerialDescriptor, index: Int, isNull: Boolean): Boolean { val destination = getDestination() isClass = descriptor.getElementDescriptor(index).kind == StructureKind.CLASS encodeByteArrayAsByteString = descriptor.isByteString(index) @@ -137,7 +142,7 @@ internal sealed class CborWriter( } } - if (cbor.configuration.encodeValueTags) { + if (cbor.configuration.encodeValueTags && !(cbor.configuration.untaggedNullValueTags && isNull)) { descriptor.getValueTags(index)?.forEach { destination.encodeTag(it) } } incrementChildren() // needed for definite len encoding, NOOP for indefinite length encoding diff --git a/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborUntaggedNullTest.kt b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborUntaggedNullTest.kt new file mode 100644 index 000000000..2df965502 --- /dev/null +++ b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborUntaggedNullTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2017-2025 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.cbor + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromHexString +import kotlinx.serialization.encodeToHexString +import kotlin.test.Test +import kotlin.test.assertEquals + +@Serializable +data class NullableDataWithTags( + @ValueTags(12uL) + val a: ULong?, + + @KeyTags(34uL) + val b: Int?, + + @KeyTags(56uL) + @ValueTags(78uL) + @ByteString val c: ByteArray?, + + @ValueTags(90uL, 12uL) + val d: String? +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as NullableDataWithTags + + if (b != other.b) return false + if (a != other.a) return false + if (!c.contentEquals(other.c)) return false + if (d != other.d) return false + + return true + } + + override fun hashCode(): Int { + var result = b ?: 0 + result = 31 * result + (a?.hashCode() ?: 0) + result = 31 * result + (c?.contentHashCode() ?: 0) + result = 31 * result + (d?.hashCode() ?: 0) + return result + } +} + +class CborUntaggedNullTest { + @Test + fun encodeAndDecodeUntaggedNullValues() { + val cbor = Cbor { + encodeValueTags = true + verifyValueTags = true + encodeKeyTags = true + verifyKeyTags = true + untaggedNullValueTags = true + } + + val o = NullableDataWithTags( + a = null, + b = null, + c = null, + d = null + ) + val hex = cbor.encodeToHexString(o) + assertEquals("bf6161a0d8226162f6d8386163f66164f6ff", hex) + + val decoded = cbor.decodeFromHexString(hex) + assertEquals(o, decoded) + } +} \ No newline at end of file