diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt deleted file mode 100644 index 15ad532f85e..00000000000 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.lagradost.cloudstream3.utils.serializers - -import android.net.Uri -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KeepGeneratedSerializer -import kotlinx.serialization.Serializable -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = NonEmptyData.Serializer::class) -data class NonEmptyData( - val title: String = "", - val tags: List = emptyList(), - val meta: Map = emptyMap(), - val name: String = "hello", -) { - object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) -} - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = WriteOnlyData.Serializer::class) -data class WriteOnlyData( - val fieldA: String = "", - val fieldB: String = "", -) { - object Serializer : WriteOnlySerializer( - WriteOnlyData.generatedSerializer(), - setOf("fieldB"), - ) -} - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = MultiWriteOnly.Serializer::class) -data class MultiWriteOnly( - val fieldA: String = "", - val fieldB: String = "", - val fieldC: String = "", -) { - object Serializer : WriteOnlySerializer( - MultiWriteOnly.generatedSerializer(), - setOf("fieldB", "fieldC"), - ) -} - -@Serializable -data class UriData( - @Serializable(with = UriSerializer::class) - val uri: Uri = Uri.EMPTY, -) - -class SerializerTest { - - @Test - fun nonEmptySerializerOmitsEmptyStrings() { - val data = NonEmptyData(title = "", name = "hello") - val result = data.toJson() - assertFalse(result.contains("title")) - assertTrue(result.contains("name")) - } - - @Test - fun nonEmptySerializerOmitsEmptyLists() { - val data = NonEmptyData(tags = emptyList(), name = "hello") - val result = data.toJson() - assertFalse(result.contains("tags")) - } - - @Test - fun nonEmptySerializerOmitsEmptyMaps() { - val data = NonEmptyData(meta = emptyMap(), name = "hello") - val result = data.toJson() - assertFalse(result.contains("meta")) - } - - @Test - fun nonEmptySerializerKeepsNonEmptyFields() { - val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) - val result = data.toJson() - assertTrue(result.contains("title")) - assertTrue(result.contains("tags")) - assertTrue(result.contains("meta")) - } - - @Test - fun nonEmptySerializerDoesNotAffectDeserialization() { - val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" - val result = parseJson(input) - assertEquals("hello", result.title) - assertEquals(listOf("a"), result.tags) - assertEquals(mapOf("k" to "v"), result.meta) - assertEquals("world", result.name) - } - - @Test - fun writeOnlySerializerOmitsFieldOnSerialize() { - val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") - val result = data.toJson() - assertTrue(result.contains("fieldA")) - assertFalse(result.contains("fieldB")) - } - - @Test - fun writeOnlySerializerDeserializesNormally() { - val input = """{"fieldA":"hello","fieldB":"secret"}""" - val result = parseJson(input) - assertEquals("hello", result.fieldA) - assertEquals("secret", result.fieldB) - } - - @Test - fun writeOnlySerializerDeserializesMissingAsDefault() { - val input = """{"fieldA":"hello"}""" - val result = parseJson(input) - assertEquals("hello", result.fieldA) - assertEquals("", result.fieldB) - } - - @Test - fun writeOnlySerializerHandlesMultipleKeys() { - val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") - val result = data.toJson() - assertTrue(result.contains("fieldA")) - assertFalse(result.contains("fieldB")) - assertFalse(result.contains("fieldC")) - } - - @Test - fun uriSerializerSerializesUriToString() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val result = data.toJson() - assertTrue(result.contains("https://example.com/path?query=1")) - } - - @Test - fun uriSerializerDeserializesStringToUri() { - val input = """{"uri":"https://example.com/path?query=1"}""" - val result = parseJson(input) - assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) - } - - @Test - fun uriSerializerRoundtripsCorrectly() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val encoded = data.toJson() - val decoded = parseJson(encoded) - assertEquals(data.uri, decoded.uri) - } -} diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt new file mode 100644 index 00000000000..3ffd3712462 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt @@ -0,0 +1,41 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@Serializable +data class UriData( + @Serializable(with = UriSerializer::class) + @SerialName("uri") val uri: Uri = Uri.EMPTY, +) + +class UriSerializerTest { + + @Test + fun uriSerializerSerializesUriToString() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val result = data.toJson() + assertTrue(result.contains("https://example.com/path?query=1")) + } + + @Test + fun uriSerializerDeserializesStringToUri() { + val input = """{"uri":"https://example.com/path?query=1"}""" + val result = parseJson(input) + assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) + } + + @Test + fun uriSerializerRoundtripsCorrectly() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data.uri, decoded.uri) + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt new file mode 100644 index 00000000000..12c8f6ed021 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull + +/** + * Replicates Jackson's ACCEPT_FLOAT_AS_INT behaviour for Int fields. + * A floating-point JSON number is truncated to Int by dropping the + * fractional part, exactly like Jackson's default truncation behaviour. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = FloatAsIntSerializer::class) + * @SerialName("count") val count: Int = 0, + * ) + */ +@Prerelease +object FloatAsIntSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("FloatAsInt", PrimitiveKind.INT) + + override fun deserialize(decoder: Decoder): Int { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeInt() + val element = jsonDecoder.decodeJsonElement() + if (element !is JsonPrimitive) return 0 + return element.intOrNull ?: element.doubleOrNull?.toInt() ?: 0 + } + + override fun serialize(encoder: Encoder, value: Int) = encoder.encodeInt(value) +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt new file mode 100644 index 00000000000..7fc394107bc --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Replicates Jackson's ACCEPT_FLOAT_AS_INT behaviour for Long fields. + * A floating-point JSON number is truncated to Long by dropping the + * fractional part, exactly like Jackson's default truncation behaviour. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = FloatAsLongSerializer::class) + * @SerialName("timestamp") val timestamp: Long = 0L, + * ) + */ +@Prerelease +object FloatAsLongSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("FloatAsLong", PrimitiveKind.LONG) + + override fun deserialize(decoder: Decoder): Long { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeLong() + val element = jsonDecoder.decodeJsonElement() + if (element !is JsonPrimitive) return 0L + return element.longOrNull ?: element.doubleOrNull?.toLong() ?: 0L + } + + override fun serialize(encoder: Encoder, value: Long) = encoder.encodeLong(value) +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/JsonTransformSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/JsonTransformSerializer.kt new file mode 100644 index 00000000000..6b9c25b5963 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/JsonTransformSerializer.kt @@ -0,0 +1,75 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer + +/** + * Composable deserialize transformer that applies multiple Jackson-equivalent + * behaviours to specific fields by key. Pass only the sets you need; all + * default to empty (no-op). + * + * Behaviours (applied in order per field): + * singleValueAsListKeys ACCEPT_SINGLE_VALUE_AS_ARRAY + * emptyStringAsNullKeys ACCEPT_EMPTY_STRING_AS_NULL_OBJECT + * emptyArrayAsNullKeys ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT + * + * Usage: + * + * @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + * @KeepGeneratedSerializer + * @Serializable(with = MyData.Serializer::class) + * data class MyData( + * @SerialName("title") val title: String? = null, + * @SerialName("tags") val tags: List? = null, + * @SerialName("meta") val meta: MyMeta? = null, + * ) { + * object Serializer : JsonTransformSerializer( + * MyData.generatedSerializer(), + * singleValueAsListKeys = setOf("tags"), + * emptyStringAsNullKeys = setOf("title", "tags"), + * emptyArrayAsNullKeys = setOf("tags", "meta"), + * ) + * } + */ +@Prerelease +abstract class JsonTransformSerializer( + tSerializer: KSerializer, + private val singleValueAsListKeys: Set = emptySet(), + private val emptyArrayAsNullKeys: Set = emptySet(), + private val emptyStringAsNullKeys: Set = emptySet(), +) : JsonTransformingSerializer(tSerializer) { + + override fun transformDeserialize(element: JsonElement): JsonElement { + if (element !is JsonObject) return element + return JsonObject(element.mapValues { (key, value) -> + var result = value + if (key in singleValueAsListKeys) { + result = when (result) { + is JsonArray -> result + JsonNull -> JsonArray(emptyList()) + else -> JsonArray(listOf(result)) + } + } + + if (key in emptyStringAsNullKeys) { + if (result is JsonPrimitive && result.isString && result.content.isEmpty()) { + result = JsonNull + } + } + + if (key in emptyArrayAsNullKeys) { + if (result is JsonArray && result.isEmpty()) { + result = JsonNull + } + } + + result + }) + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt index 82de9f7f709..28ec72d124a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt @@ -17,13 +17,13 @@ import kotlinx.serialization.json.JsonTransformingSerializer * * Usage: * - * @OptIn(ExperimentalSerializationApi::class) + * @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now * @KeepGeneratedSerializer * @Serializable(with = MyData.Serializer::class) * data class MyData( - * val tags: List = emptyList(), - * val title: String = "", - * val meta: Map = emptyMap(), + * @SerialName("tags") val tags: List = emptyList(), + * @SerialName("title") val title: String = "", + * @SerialName("meta") val meta: Map = emptyMap(), * ) { * object Serializer : NonEmptySerializer(MyData.generatedSerializer()) * } @@ -34,7 +34,6 @@ abstract class NonEmptySerializer(tSerializer: KSerializer) : override fun transformSerialize(element: JsonElement): JsonElement { if (element !is JsonObject) return element - return JsonObject(element.filterValues { value -> when (value) { is JsonPrimitive -> value.content.isNotEmpty() diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NullableStringSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NullableStringSerializer.kt new file mode 100644 index 00000000000..6755304a59b --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NullableStringSerializer.kt @@ -0,0 +1,49 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement + +/** + * Convenience serializer for nullable String fields that combines + * ACCEPT_EMPTY_STRING_AS_NULL_OBJECT with null passthrough. + * An empty JSON string (`""`) or JSON null is decoded as null. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = NullableStringSerializer::class) + * @SerialName("title") val title: String? = null, + * ) + */ +@Prerelease +object NullableStringSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("NullableString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String? { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString() + return when (val element = jsonDecoder.decodeJsonElement()) { + is JsonPrimitive -> element.content.ifEmpty { null } + JsonNull -> null + else -> jsonDecoder.json.decodeFromJsonElement(String.serializer(), element) + } + } + + override fun serialize(encoder: Encoder, value: String?) { + @OptIn(ExperimentalSerializationApi::class) // encodeNull is experimental for now + if (value == null) encoder.encodeNull() else encoder.encodeString(value) + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt index c7f412eaa5c..147e33d8529 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt @@ -12,12 +12,12 @@ import kotlinx.serialization.json.JsonTransformingSerializer * * Usage: * - * @OptIn(ExperimentalSerializationApi::class) + * @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now * @KeepGeneratedSerializer * @Serializable(with = MyData.Serializer::class) * data class MyData( - * val fieldA: String = "", - * val fieldB: String = "", + * @SerialName("fieldA") val fieldA: String = "", + * @SerialName("fieldB") val fieldB: String = "", * ) { * object Serializer : WriteOnlySerializer( * MyData.generatedSerializer(), diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/serializers/SerializersTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/serializers/SerializersTest.kt new file mode 100644 index 00000000000..89c0fea8178 --- /dev/null +++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/serializers/SerializersTest.kt @@ -0,0 +1,602 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KeepGeneratedSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = NonEmptyData.Serializer::class) +data class NonEmptyData( + @SerialName("title") val title: String = "", + @SerialName("tags") val tags: List = emptyList(), + @SerialName("meta") val meta: Map = emptyMap(), + @SerialName("name") val name: String = "hello", +) { + object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = WriteOnlyData.Serializer::class) +data class WriteOnlyData( + @SerialName("fieldA") val fieldA: String = "", + @SerialName("fieldB") val fieldB: String = "", +) { + object Serializer : WriteOnlySerializer( + WriteOnlyData.generatedSerializer(), + setOf("fieldB"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = MultiWriteOnly.Serializer::class) +data class MultiWriteOnly( + @SerialName("fieldA") val fieldA: String = "", + @SerialName("fieldB") val fieldB: String = "", + @SerialName("fieldC") val fieldC: String = "", +) { + object Serializer : WriteOnlySerializer( + MultiWriteOnly.generatedSerializer(), + setOf("fieldB", "fieldC"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = SingleValueData.Serializer::class) +data class SingleValueData( + @SerialName("tags") val tags: List = emptyList(), + @SerialName("nums") val nums: List = emptyList(), +) { + object Serializer : JsonTransformSerializer( + SingleValueData.generatedSerializer(), + singleValueAsListKeys = setOf("tags", "nums"), + ) +} + +@Serializable +data class NestedMeta( + @SerialName("key") val key: String = "", +) + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = EmptyArrayData.Serializer::class) +data class EmptyArrayData( + @SerialName("meta") val meta: NestedMeta? = null, + @SerialName("other") val other: String = "hello", +) { + object Serializer : JsonTransformSerializer( + EmptyArrayData.generatedSerializer(), + emptyArrayAsNullKeys = setOf("meta"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = EmptyStringData.Serializer::class) +data class EmptyStringData( + @SerialName("title") val title: String? = null, + @SerialName("episode") val episode: NestedMeta? = null, + @SerialName("keep") val keep: String? = "hello", +) { + object Serializer : JsonTransformSerializer( + EmptyStringData.generatedSerializer(), + emptyStringAsNullKeys = setOf("title", "episode", "keep"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = SingleValueOrNullData.Serializer::class) +data class SingleValueOrNullData( + @SerialName("tags") val tags: List? = null, + @SerialName("nums") val nums: List? = null, + @SerialName("other") val other: String = "hello", +) { + object Serializer : JsonTransformSerializer( + SingleValueOrNullData.generatedSerializer(), + singleValueAsListKeys = setOf("tags", "nums"), + emptyArrayAsNullKeys = setOf("tags", "nums"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = NullableFieldsData.Serializer::class) +data class NullableFieldsData( + @SerialName("title") val title: String? = null, + @SerialName("meta") val meta: NestedMeta? = null, + @SerialName("other") val other: String = "hello", +) { + object Serializer : JsonTransformSerializer( + NullableFieldsData.generatedSerializer(), + emptyStringAsNullKeys = setOf("title"), + emptyArrayAsNullKeys = setOf("meta"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = SingleValueOrEmptyStringData.Serializer::class) +data class SingleValueOrEmptyStringData( + @SerialName("tags") val tags: List = emptyList(), + @SerialName("title") val title: String? = null, +) { + object Serializer : JsonTransformSerializer( + SingleValueOrEmptyStringData.generatedSerializer(), + singleValueAsListKeys = setOf("tags"), + emptyStringAsNullKeys = setOf("title"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = AllTransformsData.Serializer::class) +data class AllTransformsData( + @SerialName("tags") val tags: List? = null, + @SerialName("title") val title: String? = null, + @SerialName("meta") val meta: NestedMeta? = null, + @SerialName("other") val other: String = "hello", +) { + object Serializer : JsonTransformSerializer( + AllTransformsData.generatedSerializer(), + singleValueAsListKeys = setOf("tags"), + emptyArrayAsNullKeys = setOf("tags", "meta"), + emptyStringAsNullKeys = setOf("title", "tags"), + ) +} + +@Serializable +data class FloatIntData( + @Serializable(with = FloatAsIntSerializer::class) + @SerialName("count") val count: Int = 0, +) + +@Serializable +data class FloatLongData( + @Serializable(with = FloatAsLongSerializer::class) + @SerialName("timestamp") val timestamp: Long = 0L, +) + +@Serializable +data class NullableStringData( + @Serializable(with = NullableStringSerializer::class) + @SerialName("title") val title: String? = null, +) + +class SerializersTest { + + @Test + fun nonEmptySerializerOmitsEmptyStrings() { + val data = NonEmptyData(title = "", name = "hello") + val result = data.toJson() + assertFalse(result.contains("title")) + assertTrue(result.contains("name")) + } + + @Test + fun nonEmptySerializerOmitsEmptyLists() { + val data = NonEmptyData(tags = emptyList(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("tags")) + } + + @Test + fun nonEmptySerializerOmitsEmptyMaps() { + val data = NonEmptyData(meta = emptyMap(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("meta")) + } + + @Test + fun nonEmptySerializerKeepsNonEmptyFields() { + val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) + val result = data.toJson() + assertTrue(result.contains("title")) + assertTrue(result.contains("tags")) + assertTrue(result.contains("meta")) + } + + @Test + fun nonEmptySerializerDoesNotAffectDeserialization() { + val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + assertEquals(listOf("a"), result.tags) + assertEquals(mapOf("k" to "v"), result.meta) + assertEquals("world", result.name) + } + + @Test + fun writeOnlySerializerOmitsFieldOnSerialize() { + val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + } + + @Test + fun writeOnlySerializerDeserializesNormally() { + val input = """{"fieldA":"hello","fieldB":"secret"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("secret", result.fieldB) + } + + @Test + fun writeOnlySerializerDeserializesMissingAsDefault() { + val input = """{"fieldA":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("", result.fieldB) + } + + @Test + fun writeOnlySerializerHandlesMultipleKeys() { + val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + assertFalse(result.contains("fieldC")) + } + + @Test + fun singleValueAsListDecodesArrayNormally() { + val input = """{"tags":["a","b"],"nums":[1,2]}""" + val result = parseJson(input) + assertEquals(listOf("a", "b"), result.tags) + assertEquals(listOf(1, 2), result.nums) + } + + @Test + fun singleValueAsListWrapsBareStringInList() { + val input = """{"tags":"a","nums":[1]}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + } + + @Test + fun singleValueAsListWrapsBareIntInList() { + val input = """{"tags":[],"nums":42}""" + val result = parseJson(input) + assertEquals(listOf(42), result.nums) + } + + @Test + fun singleValueAsListDecodesNullAsEmptyList() { + val input = """{"tags":null,"nums":[]}""" + val result = parseJson(input) + assertEquals(emptyList(), result.tags) + } + + @Test + fun singleValueAsListRoundtripsCorrectly() { + val data = SingleValueData(tags = listOf("x", "y"), nums = listOf(1, 2)) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun emptyArrayAsNullDecodesEmptyArrayAsNull() { + val input = """{"meta":[],"other":"hello"}""" + val result = parseJson(input) + assertNull(result.meta) + } + + @Test + fun emptyArrayAsNullDecodesNullAsNull() { + val input = """{"meta":null,"other":"hello"}""" + val result = parseJson(input) + assertNull(result.meta) + } + + @Test + fun emptyArrayAsNullDecodesObjectNormally() { + val input = """{"meta":{"key":"value"},"other":"hello"}""" + val result = parseJson(input) + assertEquals(NestedMeta("value"), result.meta) + } + + @Test + fun emptyArrayAsNullDoesNotAffectOtherFields() { + val input = """{"meta":[],"other":"world"}""" + val result = parseJson(input) + assertEquals("world", result.other) + } + + @Test + fun emptyArrayAsNullRoundtripsCorrectly() { + val data = EmptyArrayData(meta = NestedMeta("test"), other = "hello") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun emptyStringAsNullDecodesEmptyStringAsNull() { + val input = """{"title":"","episode":null,"keep":"hello"}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun emptyStringAsNullDecodesNullAsNull() { + val input = """{"title":null,"episode":null,"keep":"hello"}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun emptyStringAsNullKeepsNonEmptyString() { + val input = """{"title":"hello","episode":null,"keep":"world"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + } + + @Test + fun emptyStringAsNullDecodesObjectNormally() { + val input = """{"title":null,"episode":{"key":"value"},"keep":"hello"}""" + val result = parseJson(input) + assertEquals(NestedMeta("value"), result.episode) + } + + @Test + fun emptyStringAsNullDoesNotAffectNonEmptyFields() { + val input = """{"title":"","episode":null,"keep":"world"}""" + val result = parseJson(input) + assertEquals("world", result.keep) + } + + @Test + fun emptyStringAsNullRoundtripsCorrectly() { + val data = EmptyStringData(title = "hello", episode = NestedMeta("x"), keep = "world") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun singleValueOrNullWrapsBareValueInList() { + val input = """{"tags":"a","nums":1,"other":"hello"}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + assertEquals(listOf(1), result.nums) + } + + @Test + fun singleValueOrNullTreatsEmptyArrayAsNull() { + val input = """{"tags":[],"nums":[],"other":"hello"}""" + val result = parseJson(input) + assertNull(result.tags) + assertNull(result.nums) + } + + @Test + fun singleValueOrNullDecodesArrayNormally() { + val input = """{"tags":["a","b"],"nums":[1,2],"other":"hello"}""" + val result = parseJson(input) + assertEquals(listOf("a", "b"), result.tags) + assertEquals(listOf(1, 2), result.nums) + } + + @Test + fun singleValueOrNullDoesNotAffectOtherFields() { + val input = """{"tags":[],"nums":[],"other":"world"}""" + val result = parseJson(input) + assertEquals("world", result.other) + } + + @Test + fun nullableFieldsEmptyStringBecomesNull() { + val input = """{"title":"","meta":{"key":"value"},"other":"hello"}""" + val result = parseJson(input) + assertNull(result.title) + assertEquals(NestedMeta("value"), result.meta) + } + + @Test + fun nullableFieldsEmptyArrayBecomesNull() { + val input = """{"title":"hello","meta":[],"other":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + assertNull(result.meta) + } + + @Test + fun nullableFieldsBothNullAtOnce() { + val input = """{"title":"","meta":[],"other":"hello"}""" + val result = parseJson(input) + assertNull(result.title) + assertNull(result.meta) + } + + @Test + fun nullableFieldsDoesNotAffectOtherFields() { + val input = """{"title":"","meta":[],"other":"world"}""" + val result = parseJson(input) + assertEquals("world", result.other) + } + + @Test + fun nullableFieldsRoundtripsCorrectly() { + val data = NullableFieldsData(title = "hello", meta = NestedMeta("x"), other = "world") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun singleValueOrEmptyStringWrapsBareString() { + val input = """{"tags":"a","title":"hello"}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + assertEquals("hello", result.title) + } + + @Test + fun singleValueOrEmptyStringTurnsEmptyTitleToNull() { + val input = """{"tags":["a"],"title":""}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + assertNull(result.title) + } + + @Test + fun singleValueOrEmptyStringRoundtripsCorrectly() { + val data = SingleValueOrEmptyStringData(tags = listOf("x"), title = "hello") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun allTransformsWrapsBareStringInList() { + val input = """{"tags":"a","title":"hello","meta":{"key":"value"},"other":"world"}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + } + + @Test + fun allTransformsEmptyArrayTagsBecomesNull() { + val input = """{"tags":[],"title":"hello","meta":{"key":"value"},"other":"world"}""" + val result = parseJson(input) + assertNull(result.tags) + } + + @Test + fun allTransformsEmptyMetaBecomesNull() { + val input = """{"tags":["a"],"title":"hello","meta":[],"other":"world"}""" + val result = parseJson(input) + assertNull(result.meta) + } + + @Test + fun allTransformsEmptyTitleBecomesNull() { + val input = """{"tags":["a"],"title":"","meta":{"key":"value"},"other":"world"}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun allTransformsAllNullAtOnce() { + val input = """{"tags":[],"title":"","meta":[],"other":"world"}""" + val result = parseJson(input) + assertNull(result.tags) + assertNull(result.title) + assertNull(result.meta) + } + + @Test + fun allTransformsDoesNotAffectOtherFields() { + val input = """{"tags":[],"title":"","meta":[],"other":"world"}""" + val result = parseJson(input) + assertEquals("world", result.other) + } + + @Test + fun allTransformsRoundtripsCorrectly() { + val data = AllTransformsData(tags = listOf("a"), title = "hello", meta = NestedMeta("x"), other = "world") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun floatAsIntTruncatesFloat() { + val input = """{"count":3.9}""" + val result = parseJson(input) + assertEquals(3, result.count) + } + + @Test + fun floatAsIntDecodesIntNormally() { + val input = """{"count":42}""" + val result = parseJson(input) + assertEquals(42, result.count) + } + + @Test + fun floatAsIntHandlesNegativeFloat() { + val input = """{"count":-2.7}""" + val result = parseJson(input) + assertEquals(-2, result.count) + } + + @Test + fun floatAsIntRoundtripsCorrectly() { + val data = FloatIntData(count = 7) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun floatAsLongTruncatesFloat() { + val input = """{"timestamp":1234567890.9}""" + val result = parseJson(input) + assertEquals(1234567890L, result.timestamp) + } + + @Test + fun floatAsLongDecodesLongNormally() { + val input = """{"timestamp":9999999999}""" + val result = parseJson(input) + assertEquals(9999999999L, result.timestamp) + } + + @Test + fun floatAsLongHandlesNegativeFloat() { + val input = """{"timestamp":-100.6}""" + val result = parseJson(input) + assertEquals(-100L, result.timestamp) + } + + @Test + fun floatAsLongRoundtripsCorrectly() { + val data = FloatLongData(timestamp = 1700000000L) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun nullableStringDecodesEmptyStringAsNull() { + val input = """{"title":""}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun nullableStringDecodesNullAsNull() { + val input = """{"title":null}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun nullableStringKeepsNonEmptyString() { + val input = """{"title":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + } + + @Test + fun nullableStringRoundtripsCorrectly() { + val data = NullableStringData(title = "world") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } +}