Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/serialization-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Once the project is set up, we can start serializing some classes.
* <a name='definite-vs-indefinite-length-encoding'></a>[Definite vs. Indefinite Length Encoding](formats.md#definite-vs-indefinite-length-encoding)
* <a name='tags-and-labels'></a>[Tags and Labels](formats.md#tags-and-labels)
* <a name='arrays'></a>[Arrays](formats.md#arrays)
* <a name='nullability-of-properties'></a>[Nullability of Properties](formats.md#nullability-of-properties)
* <a name='custom-cbor-specific-serializers'></a>[Custom CBOR-specific Serializers](formats.md#custom-cbor-specific-serializers)
* <a name='protobuf-experimental'></a>[ProtoBuf (experimental)](formats.md#protobuf-experimental)
* <a name='field-numbers'></a>[Field numbers](formats.md#field-numbers)
Expand Down
7 changes: 7 additions & 0 deletions formats/cbor/api/kotlinx-serialization-cbor.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()V
}

public final class kotlinx/serialization/cbor/CborTag {
public static final field BASE16 J
public static final field BASE64 J
Expand Down
4 changes: 4 additions & 0 deletions formats/cbor/api/kotlinx-serialization-cbor.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ open annotation class kotlinx.serialization.cbor/CborLabel : kotlin/Annotation {
final fun <get-label>(): kotlin/Long // kotlinx.serialization.cbor/CborLabel.label.<get-label>|<get-label>(){}[0]
}

open annotation class kotlinx.serialization.cbor/CborNullAsEmptyMap : kotlin/Annotation { // kotlinx.serialization.cbor/CborNullAsEmptyMap|null[0]
constructor <init>() // kotlinx.serialization.cbor/CborNullAsEmptyMap.<init>|<init>(){}[0]
}

open annotation class kotlinx.serialization.cbor/KeyTags : kotlin/Annotation { // kotlinx.serialization.cbor/KeyTags|null[0]
constructor <init>(kotlin/ULongArray...) // kotlinx.serialization.cbor/KeyTags.<init>|<init>(kotlin.ULongArray...){}[0]

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -103,7 +103,7 @@ internal sealed class CborWriter(


override fun encodeNull() {
if (isClass) getDestination().encodeEmptyMap()
if (encodeNullAsEmptyMap) getDestination().encodeEmptyMap()
else getDestination().encodeNull()
}

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)