diff --git a/docs/formats.md b/docs/formats.md index ec4e6cb82..f4464fea0 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -16,6 +16,7 @@ stable, these are currently experimental features of Kotlin Serialization. * [Definite vs. Indefinite Length Encoding](#definite-vs-indefinite-length-encoding) * [Tags and Labels](#tags-and-labels) * [Arrays](#arrays) + * [Nullability of Properties](#nullability-of-properties) * [Custom CBOR-specific Serializers](#custom-cbor-specific-serializers) * [ProtoBuf (experimental)](#protobuf-experimental) * [Field numbers](#field-numbers) @@ -308,6 +309,10 @@ When annotated with `@CborArray`, serialization of the same object will produce ``` This may be used to encode COSE structures, see [RFC 9052 2. Basic COSE Structure](https://www.rfc-editor.org/rfc/rfc9052#section-2). +### Nullability of Properties +Some standards, like COSE, tend to encode the absence of a complex property as an empty map (because the complex property itself +consists only of nullable properties). This cannot be modelled elegantly, such that the null-safety fo Kotlin can be leveraged. +To work around this, complex nullable properties can be annotated with [`@CborNullAsEmptyMap`](CborNullAsEmptyMap.kt), to emulate this behaviour. ### Custom CBOR-specific Serializers Cbor encoders and decoders implement the interfaces [CborEncoder](CborEncoder.kt) and [CborDecoder](CborDecoder.kt), respectively. diff --git a/docs/serialization-guide.md b/docs/serialization-guide.md index c01eb6231..0ba5eb3a5 100644 --- a/docs/serialization-guide.md +++ b/docs/serialization-guide.md @@ -154,6 +154,7 @@ Once the project is set up, we can start serializing some classes. * [Definite vs. Indefinite Length Encoding](formats.md#definite-vs-indefinite-length-encoding) * [Tags and Labels](formats.md#tags-and-labels) * [Arrays](formats.md#arrays) + * [Nullability of Properties](formats.md#nullability-of-properties) * [Custom CBOR-specific Serializers](formats.md#custom-cbor-specific-serializers) * [ProtoBuf (experimental)](formats.md#protobuf-experimental) * [Field numbers](formats.md#field-numbers) diff --git a/formats/cbor/api/kotlinx-serialization-cbor.api b/formats/cbor/api/kotlinx-serialization-cbor.api index 6b580add0..d9a5c337d 100644 --- a/formats/cbor/api/kotlinx-serialization-cbor.api +++ b/formats/cbor/api/kotlinx-serialization-cbor.api @@ -101,6 +101,13 @@ public final synthetic class kotlinx/serialization/cbor/CborLabel$Impl : kotlinx public final synthetic fun label ()J } +public abstract interface annotation class kotlinx/serialization/cbor/CborNullAsEmptyMap : java/lang/annotation/Annotation { +} + +public final synthetic class kotlinx/serialization/cbor/CborNullAsEmptyMap$Impl : kotlinx/serialization/cbor/CborNullAsEmptyMap { + public fun ()V +} + public final class kotlinx/serialization/cbor/CborTag { public static final field BASE16 J public static final field BASE64 J diff --git a/formats/cbor/api/kotlinx-serialization-cbor.klib.api b/formats/cbor/api/kotlinx-serialization-cbor.klib.api index 346416c8f..b55acf628 100644 --- a/formats/cbor/api/kotlinx-serialization-cbor.klib.api +++ b/formats/cbor/api/kotlinx-serialization-cbor.klib.api @@ -21,6 +21,10 @@ open annotation class kotlinx.serialization.cbor/CborLabel : kotlin/Annotation { final fun (): kotlin/Long // kotlinx.serialization.cbor/CborLabel.label.|(){}[0] } +open annotation class kotlinx.serialization.cbor/CborNullAsEmptyMap : kotlin/Annotation { // kotlinx.serialization.cbor/CborNullAsEmptyMap|null[0] + constructor () // kotlinx.serialization.cbor/CborNullAsEmptyMap.|(){}[0] +} + open annotation class kotlinx.serialization.cbor/KeyTags : kotlin/Annotation { // kotlinx.serialization.cbor/KeyTags|null[0] constructor (kotlin/ULongArray...) // kotlinx.serialization.cbor/KeyTags.|(kotlin.ULongArray...){}[0] diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborNullAsEmptyMap.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborNullAsEmptyMap.kt new file mode 100644 index 000000000..03b46f089 --- /dev/null +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborNullAsEmptyMap.kt @@ -0,0 +1,42 @@ +package kotlinx.serialization.cbor + +import kotlinx.serialization.* + +/** + * Marks a complex property to be encoded as an empty map when null, instead of CBOR `null`. + * + * This is useful for COSE encoding, because COSE known protected and unprotected headers, for example, + * and the compiler handles null checks, while checks for empty maps would lead to duplicated spaghetti code. + * + * Example usage: + * + * ``` + * + * @Serializable + * data class ClassWNullableAsMap( + * @SerialName("nullable") + * @CborNullAsEmptyMap + * val nullable: NullableClass? + * ) + * + * @Serializable + * data class NullableClass( + * val property: String + * ) + * + * Cbor.encodeToByteArray(ClassWNullableAsMap(nullable = null)) + * ``` + * + * will produce bytes `0xbf686e756c6c61626c65a0ff`, or in diagnostic notation: + * + * ``` + *a1 # map(1) + * 68 # text(8) + * 6e756c6c61626c65 # "nullable" + * a0 # map(0) + * ``` + */ +@SerialInfo +@Target(AnnotationTarget.PROPERTY) +@ExperimentalSerializationApi +public annotation class CborNullAsEmptyMap 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 e84fbd8cd..1447e1a27 100644 --- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt +++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt @@ -28,7 +28,7 @@ internal sealed class CborWriter( override val cbor: Cbor, protected val output: ByteArrayOutput, ) : AbstractEncoder(), CborEncoder { - protected var isClass = false + protected var encodeNullAsEmptyMap = false protected var encodeByteArrayAsByteString = false @@ -103,7 +103,7 @@ internal sealed class CborWriter( override fun encodeNull() { - if (isClass) getDestination().encodeEmptyMap() + if (encodeNullAsEmptyMap) getDestination().encodeEmptyMap() else getDestination().encodeNull() } @@ -117,7 +117,7 @@ internal sealed class CborWriter( override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { val destination = getDestination() - isClass = descriptor.getElementDescriptor(index).kind == StructureKind.CLASS + encodeNullAsEmptyMap = descriptor.getElementAnnotations(index).find { it is CborNullAsEmptyMap } != null encodeByteArrayAsByteString = descriptor.isByteString(index) val name = descriptor.getElementName(index) diff --git a/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborNullAsEmptyMapTest.kt b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborNullAsEmptyMapTest.kt new file mode 100644 index 000000000..445f97e96 --- /dev/null +++ b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborNullAsEmptyMapTest.kt @@ -0,0 +1,178 @@ +package kotlinx.serialization.cbor + +import kotlinx.serialization.* +import kotlin.test.* + + +class CborNullAsEmptyMapTest { + + + @Test + fun nullableAsMap() { + /** + * a1 # map(1) + * 68 # text(8) + * 6e756c6c61626c65 # "nullable" + * a0 # map(0) + */ + val referenceHexString = "a1686e756c6c61626c65a0" + val reference = ClassWNullableAsMap(nullable = null) + + val cbor = Cbor.CoseCompliant + + assertEquals(referenceHexString, cbor.encodeToHexString(ClassWNullableAsMap.serializer(), reference)) + assertEquals(reference, cbor.decodeFromHexString(ClassWNullableAsMap.serializer(), referenceHexString)) + } + + @Test + fun nullableAsNull() { + /** + * a1 # map(1) + * 68 # text(8) + * 6e756c6c61626c65 # "nullable" + * f6 # null, simple(22) + */ + val referenceHexString = "a1686e756c6c61626c65f6" + val reference = ClassWNullableAsNull(nullable = null) + + + val cbor = Cbor.CoseCompliant + + assertEquals(referenceHexString, cbor.encodeToHexString(ClassWNullableAsNull.serializer(), reference)) + assertEquals(reference, cbor.decodeFromHexString(ClassWNullableAsNull.serializer(), referenceHexString)) + } + + @Test + fun nullableAsMapWithDefaultNull() { + /** + * a1 # map(1) + * 68 # text(8) + * 6e756c6c61626c65 # "nullable" + * a0 # map(0) + */ + val referenceHexString = "a1686e756c6c61626c65a0" + val reference = ClassWNullableAsMapWithDefaultValueNull() + + val cbor = Cbor { + useDefiniteLengthEncoding = true + encodeDefaults = true + } + + assertEquals( + referenceHexString, + cbor.encodeToHexString(ClassWNullableAsMapWithDefaultValueNull.serializer(), reference) + ) + assertEquals( + reference, + cbor.decodeFromHexString(ClassWNullableAsMapWithDefaultValueNull.serializer(), referenceHexString) + ) + } + + @Test + fun nullableAsNullWithDefaultNull() { + /** + * a1 # map(1) + * 68 # text(8) + * 6e756c6c61626c65 # "nullable" + * f6 # null, simple(22) + */ + val referenceHexString = "a1686e756c6c61626c65f6" + val reference = ClassWNullableAsNullWithDefaultValueNull() + + + val cbor = Cbor { + useDefiniteLengthEncoding = true + encodeDefaults = true + } + + assertEquals( + referenceHexString, + cbor.encodeToHexString(ClassWNullableAsNullWithDefaultValueNull.serializer(), reference) + ) + assertEquals( + reference, + cbor.decodeFromHexString(ClassWNullableAsNullWithDefaultValueNull.serializer(), referenceHexString) + ) + } + @Test + fun nullableAsMapWithDefaultNullNoEncodeDefaults() { + /** + * a0 # map(0) + */ + val referenceHexString = "a0" + val reference = ClassWNullableAsMapWithDefaultValueNull() + + val cbor = Cbor { + useDefiniteLengthEncoding = true + encodeDefaults = false + } + + assertEquals( + referenceHexString, + cbor.encodeToHexString(ClassWNullableAsMapWithDefaultValueNull.serializer(), reference) + ) + assertEquals( + reference, + cbor.decodeFromHexString(ClassWNullableAsMapWithDefaultValueNull.serializer(), referenceHexString) + ) + } + + @Test + fun nullableAsNullWithDefaultNullNoEncodeDefaults() { + /** + * a0 # map(0) + */ + val referenceHexString = "a0" + val reference = ClassWNullableAsNullWithDefaultValueNull() + + + val cbor = Cbor { + useDefiniteLengthEncoding = true + encodeDefaults = false + } + + assertEquals( + referenceHexString, + cbor.encodeToHexString(ClassWNullableAsNullWithDefaultValueNull.serializer(), reference) + ) + assertEquals( + reference, + cbor.decodeFromHexString(ClassWNullableAsNullWithDefaultValueNull.serializer(), referenceHexString) + ) + } +} + +@Serializable +data class ClassWNullableAsMap( + @SerialName("nullable") + @CborNullAsEmptyMap + val nullable: NullableClass? +) + +@Serializable +data class ClassWNullableAsMapWithDefaultValueNull( + @SerialName("nullable") + @CborNullAsEmptyMap + val nullable: NullableClass? = null +) + + +@Serializable +data class ClassWNullableAsNull( + @SerialName("nullable") + val nullable: NullableClass? +) + +@Serializable +data class ClassWNullableAsNullWithDefaultValueNull( + @SerialName("nullable") + val nullable: NullableClass? = null +) + +@Serializable +data class NullableClass( + val property: String +) + + +