diff --git a/GeneratorScripts/src/main/kotlin/autogenscripts/GenTypes.kt b/GeneratorScripts/src/main/kotlin/autogenscripts/GenTypes.kt index 18bfb4fbc..2d60ec101 100644 --- a/GeneratorScripts/src/main/kotlin/autogenscripts/GenTypes.kt +++ b/GeneratorScripts/src/main/kotlin/autogenscripts/GenTypes.kt @@ -75,18 +75,15 @@ val fileFieldEnumOverrides = mapOf( // ============================================================ // Sealed interfaces that are not generated by dukat but must exist in the datatypes package. -// Maps the interface name to its @JsonClassDiscriminator field, and whether it needs @Serializable. -data class SealedInterface(val discriminator: String, val serializable: Boolean = false) - -// Interfaces that are deserialized polymorphically (as response fields) must be @Serializable, -// otherwise the class discriminator is not resolved at runtime. -// TODO refactor +// Maps the interface name to its @JsonClassDiscriminator field. +// They are always generated as @Serializable, otherwise the class discriminator +// is not resolved when they are deserialized polymorphically (as response fields). val sealedInterfaces = mapOf( - "PostOrCommentOrPrivateMessage" to SealedInterface("type_", serializable = true), - "ResolveObjectView" to SealedInterface("type_", serializable = true), - "ReportCombinedView" to SealedInterface("type_", serializable = true), - "NotificationData" to SealedInterface("type_", serializable = true), - "PostCommentCombinedView" to SealedInterface("type_", serializable = true), + "PostOrCommentOrPrivateMessage" to "type_", + "ResolveObjectView" to "type_", + "ReportCombinedView" to "type_", + "NotificationData" to "type_", + "PostCommentCombinedView" to "type_", ) // Which data classes implement which sealed interfaces and what @SerialName they carry. @@ -218,19 +215,18 @@ fun addSealedInterfaceImpl(content: String, fileName: String, imports: MutableSe /** Generates a sealed interface .kt file for each entry in sealedInterfaces. */ fun writeSealedInterfaces(datatypesDir: File, packageName: String) { - for ((name, info) in sealedInterfaces) { - val serializableImport = if (info.serializable) "\nimport kotlinx.serialization.Serializable" else "" - val serializableAnnotation = if (info.serializable) "\n@Serializable" else "" - + for ((name, discriminator) in sealedInterfaces) { File(datatypesDir, "$name.kt").writeText( """ package $packageName - import kotlinx.serialization.ExperimentalSerializationApi$serializableImport + import kotlinx.serialization.ExperimentalSerializationApi + import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator - @OptIn(ExperimentalSerializationApi::class)$serializableAnnotation - @JsonClassDiscriminator("${info.discriminator}") + @OptIn(ExperimentalSerializationApi::class) + @Serializable + @JsonClassDiscriminator("$discriminator") internal sealed interface $name """.trimIndent() + "\n" ) @@ -265,7 +261,8 @@ fun processDukatFile(typeFile: File, fileName: String, packageName: String, root if (!body.endsWith("\n")) appendLine() }) - typeFile.delete() + // Older dukat versions emit `.d.kt` files, newer ones emit `.kt`, which is overwritten above. + if (typeFile.canonicalPath != outputFile.canonicalPath) typeFile.delete() } // ============================================================ @@ -311,8 +308,10 @@ fun downloadTypes( println("Starting Dukat process...") val proc = ProcessBuilder("bash", "-ic", "dukat temp/*.ts", "exit") .directory(datatypesDir) + .redirectErrorStream(true) .start() println(String(proc.inputStream.readAllBytes())) + check(proc.waitFor() == 0) { "Dukat failed" } // Process each dukat-generated .d.kt → clean .kt datatypesDir.listFiles()!!.forEach { typeFile -> diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/NotificationData.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/NotificationData.kt index f59efcc75..061506a1a 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/NotificationData.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/NotificationData.kt @@ -3,9 +3,11 @@ package it.vercruysse.lemmyapi.datatypes import it.vercruysse.lemmyapi.CommonParcelize import it.vercruysse.lemmyapi.DatatypeRoot import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @CommonParcelize @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") sealed interface NotificationData : DatatypeRoot diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/PostCommentCombinedView.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/PostCommentCombinedView.kt index fba27363b..596d8ac63 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/PostCommentCombinedView.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/PostCommentCombinedView.kt @@ -3,9 +3,11 @@ package it.vercruysse.lemmyapi.datatypes import it.vercruysse.lemmyapi.CommonParcelize import it.vercruysse.lemmyapi.DatatypeRoot import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @CommonParcelize @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") sealed interface PostCommentCombinedView : DatatypeRoot diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/PostOrCommentOrPrivateMessage.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/PostOrCommentOrPrivateMessage.kt index 4b3e85f14..fe7165926 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/PostOrCommentOrPrivateMessage.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/PostOrCommentOrPrivateMessage.kt @@ -3,9 +3,11 @@ package it.vercruysse.lemmyapi.datatypes import it.vercruysse.lemmyapi.CommonParcelize import it.vercruysse.lemmyapi.DatatypeRoot import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @CommonParcelize @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") sealed interface PostOrCommentOrPrivateMessage : DatatypeRoot diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/ReportCombinedView.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/ReportCombinedView.kt index f66724db4..18d414d67 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/ReportCombinedView.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/ReportCombinedView.kt @@ -3,9 +3,11 @@ package it.vercruysse.lemmyapi.datatypes import it.vercruysse.lemmyapi.CommonParcelize import it.vercruysse.lemmyapi.DatatypeRoot import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @CommonParcelize @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") sealed interface ReportCombinedView : DatatypeRoot diff --git a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/ResolveObjectView.kt b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/ResolveObjectView.kt index 82d85ebb7..51845b752 100644 --- a/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/ResolveObjectView.kt +++ b/app/src/commonMain/kotlin/it/vercruysse/lemmyapi/datatypes/ResolveObjectView.kt @@ -3,9 +3,11 @@ package it.vercruysse.lemmyapi.datatypes import it.vercruysse.lemmyapi.CommonParcelize import it.vercruysse.lemmyapi.DatatypeRoot import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator @CommonParcelize @OptIn(ExperimentalSerializationApi::class) +@Serializable @JsonClassDiscriminator("type_") sealed interface ResolveObjectView : DatatypeRoot diff --git a/app/src/jvmTest/kotlin/SealedInterfaceSerializationTest.kt b/app/src/jvmTest/kotlin/SealedInterfaceSerializationTest.kt new file mode 100644 index 000000000..87acf5a99 --- /dev/null +++ b/app/src/jvmTest/kotlin/SealedInterfaceSerializationTest.kt @@ -0,0 +1,66 @@ +import it.vercruysse.lemmyapi.IGNORE_UNKNOWN_KEYS_JSON +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import it.vercruysse.lemmyapi.datatypes.PersonView as UniPersonView +import it.vercruysse.lemmyapi.datatypes.ResolveObjectView as UniResolveObjectView +import it.vercruysse.lemmyapi.datatypes.SearchResponse as UniSearchResponse +import it.vercruysse.lemmyapi.v1.x0.x0.datatypes.PersonView as V1PersonView +import it.vercruysse.lemmyapi.v1.x0.x0.datatypes.ResolveObjectView as V1ResolveObjectView +import it.vercruysse.lemmyapi.v1.x0.x0.datatypes.SearchResponse as V1SearchResponse + +class SealedInterfaceSerializationTest { + private val searchResponseJson = + """ + { + "resolve": { + "type_": "person", + "person": { + "id": 1, "name": "name", "published_at": "2025-01-01T00:00:00Z", "ap_id": "https://example.com/u/name", + "local": true, "last_refreshed_at": "2025-01-01T00:00:00Z", "deleted": false, "bot_account": false, + "instance_id": 1, "post_count": 0, "comment_count": 0 + }, + "is_admin": false, + "banned": false + }, + "comments": [], "posts": [], "communities": [], "persons": [], "multi_communities": [] + } + """.trimIndent() + + @Test + fun `generated v1 sealed interface is resolved by its class discriminator`() { + val response = IGNORE_UNKNOWN_KEYS_JSON.decodeFromString(searchResponseJson) + + val resolved = assertIs(response.resolve) + assertEquals("name", resolved.person.name) + } + + @Test + fun `generated v1 sealed interface is encoded with its class discriminator`() { + val response = IGNORE_UNKNOWN_KEYS_JSON.decodeFromString(searchResponseJson) + + val encoded = IGNORE_UNKNOWN_KEYS_JSON.encodeToString(response.resolve!!) + + assertTrue("\"type_\":\"person\"" in encoded, encoded) + } + + @Test + fun `unified sealed interface is resolved by its class discriminator`() { + val response = IGNORE_UNKNOWN_KEYS_JSON.decodeFromString(searchResponseJson) + + val resolved = assertIs(response.resolve) + assertEquals("name", resolved.person.name) + } + + @Test + fun `unified sealed interface survives a round trip`() { + val response = IGNORE_UNKNOWN_KEYS_JSON.decodeFromString(searchResponseJson) + + val roundTripped = + IGNORE_UNKNOWN_KEYS_JSON.decodeFromString(IGNORE_UNKNOWN_KEYS_JSON.encodeToString(response)) + + assertEquals(response, roundTripped) + assertIs(roundTripped.resolve) + } +}