refactor: migrate FeedDefinitionSerializer to kotlinx.serialization

The one Jackson holdout in commons/commonMain. Migrating it unblocks
the iOS purity gate for :commons (Phase 1 of the iOS plan).

- Rewrites FeedDefinitionSerializer with the kotlinx.serialization JSON
  tree API (JsonObject / JsonArray / JsonPrimitive). Wire format is
  byte-identical, so users' existing on-disk custom-feed definitions
  keep deserializing — covered by a new parsesLegacyJacksonOutput test
  that pins a hand-written Jackson-shaped JSON blob.
- Adds :commons:verifyKmpPurity (mirrors the one in :quartz) and wires
  it into the CI lint job alongside :quartz:verifyKmpPurity.
- Pulls in kotlinx-serialization-json as a commonMain dep; the
  serialization plugin was already applied on :commons.
This commit is contained in:
Claude
2026-05-24 16:41:00 +00:00
parent e0c3b18731
commit b27fc34786
4 changed files with 164 additions and 62 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Linter (gradle)
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
build-desktop:
needs: lint
+52
View File
@@ -75,6 +75,10 @@ kotlin {
// Immutable collections
api(libs.kotlinx.collections.immutable)
// JSON for custom-feed definitions (KMP — replaces Jackson
// for the one commonMain serializer that was blocking iOS).
implementation(libs.kotlinx.serialization.json)
// Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources)
@@ -150,3 +154,51 @@ compose.resources {
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
generateResClass = always
}
// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale
// there. Commons gains this gate once FeedDefinitionSerializer.kt has been
// migrated off Jackson; future commonMain code must not reintroduce JVM-only
// JSON / HTTP deps.
val verifyKmpPurity by tasks.registering {
group = "verification"
description = "Fails if iOS-targeted source sets import JVM-only deps."
val checkedDirs =
listOf(
"src/commonMain", "src/commonTest",
"src/appleMain", "src/appleTest",
"src/nativeMain", "src/nativeTest",
"src/iosMain", "src/iosTest",
"src/iosArm64Main", "src/iosArm64Test",
"src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test",
"src/linuxMain", "src/linuxTest",
"src/linuxX64Main", "src/linuxX64Test",
"src/macosMain", "src/macosTest",
"src/macosArm64Main", "src/macosArm64Test",
).map { layout.projectDirectory.dir(it).asFile }
.filter { it.exists() }
inputs.files(checkedDirs)
doLast {
val forbidden = listOf("com.fasterxml.jackson", "okhttp3")
val offenders =
checkedDirs.flatMap { dir ->
dir.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.flatMap { file ->
file.readLines().withIndex().mapNotNull { (idx, line) ->
forbidden.firstOrNull { line.contains(it) }?.let { hit ->
"${file.relativeTo(rootDir)}:${idx + 1}: '$hit'"
}
}
}
}
if (offenders.isNotEmpty()) {
throw GradleException(
"iOS-targeted source sets must not reference JVM-only deps " +
"(Jackson, OkHttp). Move the offending code to jvmAndroid/ " +
"or behind an expect/actual:\n " + offenders.joinToString("\n "),
)
}
}
}
tasks.named("check").configure { dependsOn(verifyKmpPurity) }
@@ -20,29 +20,37 @@
*/
package com.vitorpamplona.amethyst.commons.feeds.custom
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
object FeedDefinitionSerializer {
private val mapper = ObjectMapper()
private val json = Json { ignoreUnknownKeys = true }
fun serializeList(feeds: List<FeedDefinition>): String {
val array = mapper.createArrayNode()
feeds.forEach { feed -> array.add(serializeFeed(feed)) }
return mapper.writeValueAsString(array)
val array =
buildJsonArray {
feeds.forEach { add(serializeFeed(it)) }
}
return json.encodeToString(JsonArray.serializer(), array)
}
fun deserializeList(json: String): List<FeedDefinition> {
if (json.isBlank()) return emptyList()
val array = mapper.readTree(json) as? ArrayNode ?: return emptyList()
return array.mapNotNull { node -> deserializeFeed(node) }
fun deserializeList(jsonString: String): List<FeedDefinition> {
if (jsonString.isBlank()) return emptyList()
val array = json.parseToJsonElement(jsonString) as? JsonArray ?: return emptyList()
return array.mapNotNull { node -> (node as? JsonObject)?.let { deserializeFeed(it) } }
}
private fun serializeFeed(feed: FeedDefinition): ObjectNode =
mapper.createObjectNode().apply {
private fun serializeFeed(feed: FeedDefinition): JsonObject =
buildJsonObject {
put("id", feed.id)
put("name", feed.name)
put("emoji", feed.emoji)
@@ -50,25 +58,25 @@ object FeedDefinitionSerializer {
put("pinOrder", feed.pinOrder)
put("refreshMode", feed.refreshMode.name)
put("createdAt", feed.createdAt)
set<ObjectNode>("source", serializeSource(feed.source))
put("source", serializeSource(feed.source))
}
private fun deserializeFeed(node: JsonNode): FeedDefinition? {
val id = node.get("id")?.asText() ?: return null
val name = node.get("name")?.asText() ?: return null
val emoji = node.get("emoji")?.asText() ?: ""
val pinned = node.get("pinned")?.asBoolean() ?: false
val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE
private fun deserializeFeed(node: JsonObject): FeedDefinition? {
val id = node.string("id") ?: return null
val name = node.string("name") ?: return null
val emoji = node.string("emoji") ?: ""
val pinned = node.bool("pinned") ?: false
val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE
val refreshMode =
node.get("refreshMode")?.asText()?.let {
node.string("refreshMode")?.let {
try {
RefreshMode.valueOf(it)
} catch (_: Exception) {
RefreshMode.LIVE_STREAM
}
} ?: RefreshMode.LIVE_STREAM
val createdAt = node.get("createdAt")?.asLong() ?: 0L
val source = node.get("source")?.let { deserializeSource(it) } ?: return null
val createdAt = node.long("createdAt") ?: 0L
val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null
return FeedDefinition(
id = id,
@@ -82,17 +90,20 @@ object FeedDefinitionSerializer {
)
}
private fun serializeSource(source: FeedSource): ObjectNode =
mapper.createObjectNode().apply {
private fun serializeSource(source: FeedSource): JsonObject =
buildJsonObject {
when (source) {
is FeedSource.Filter -> {
put("type", "filter")
set<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
set<ArrayNode>("kinds", mapper.valueToTree(source.kinds.toList()))
put("hashtags", stringArray(source.hashtags))
put("authors", stringArray(source.authors))
put("relays", stringArray(source.relays))
put("excludeAuthors", stringArray(source.excludeAuthors))
put("excludeKeywords", stringArray(source.excludeKeywords))
put(
"kinds",
buildJsonArray { source.kinds.forEach { add(JsonPrimitive(it)) } },
)
}
is FeedSource.PeopleList -> {
@@ -131,61 +142,73 @@ object FeedDefinitionSerializer {
}
}
private fun deserializeSource(node: JsonNode): FeedSource? {
val type = node.get("type")?.asText() ?: return null
private fun deserializeSource(node: JsonObject): FeedSource? {
val type = node.string("type") ?: return null
return when (type) {
"filter" -> {
FeedSource.Filter(
hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null,
authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null,
relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null,
excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null,
kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null,
hashtags = node.stringList("hashtags") ?: return null,
authors = node.stringList("authors") ?: return null,
relays = node.stringList("relays") ?: return null,
excludeAuthors = node.stringList("excludeAuthors") ?: return null,
excludeKeywords = node.stringList("excludeKeywords") ?: return null,
kinds = node.intList("kinds") ?: return null,
)
}
"people_list" -> {
FeedSource.PeopleList(
kind = node.get("kind")?.asInt() ?: 30000,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 30000,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"interest_set" -> {
FeedSource.InterestSet(
kind = node.get("kind")?.asInt() ?: 30015,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 30015,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"dvm" -> {
FeedSource.DVM(
kind = node.get("kind")?.asInt() ?: 31990,
pubkey = node.get("pubkey")?.asText() ?: return null,
dTag = node.get("dTag")?.asText() ?: return null,
kind = node.int("kind") ?: 31990,
pubkey = node.string("pubkey") ?: return null,
dTag = node.string("dTag") ?: return null,
)
}
"single_relay" -> {
FeedSource.SingleRelay(
url = node.get("url")?.asText() ?: return null,
url = node.string("url") ?: return null,
)
}
"global" -> {
FeedSource.Global
}
"following" -> {
FeedSource.Following
}
else -> {
null
}
"global" -> FeedSource.Global
"following" -> FeedSource.Following
else -> null
}
}
private fun stringArray(values: Iterable<String>): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }
private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull
private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull
private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull
private fun JsonObject.stringList(key: String) =
(this[key] as? JsonArray)
?.map { (it as? JsonPrimitive)?.content.orEmpty() }
?.toImmutableList()
private fun JsonObject.intList(key: String) =
(this[key] as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.intOrNull }
?.toImmutableList()
}
@@ -212,6 +212,33 @@ class FeedDefinitionSerializerTest {
assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" "))
}
@Test
fun parsesLegacyJacksonOutput() {
// Wire format previously emitted by ObjectMapper. Users have feed
// definitions stored on disk in exactly this shape — the new
// kotlinx.serialization-backed parser must keep accepting it.
val legacy =
"""[{"id":"x","name":"Bitcoin","emoji":"₿","pinned":true,"pinOrder":0,""" +
""""refreshMode":"LIVE_STREAM","createdAt":1000,""" +
""""source":{"type":"filter","hashtags":["bitcoin","btc"],"authors":["abc"],""" +
""""relays":[],"excludeAuthors":[],"excludeKeywords":[],"kinds":[1,6]}}]"""
val feeds = FeedDefinitionSerializer.deserializeList(legacy)
assertEquals(1, feeds.size)
val feed = feeds[0]
assertEquals("x", feed.id)
assertEquals("Bitcoin", feed.name)
assertEquals("", feed.emoji)
assertTrue(feed.pinned)
assertEquals(0, feed.pinOrder)
assertEquals(RefreshMode.LIVE_STREAM, feed.refreshMode)
assertEquals(1000L, feed.createdAt)
val source = feed.source as FeedSource.Filter
assertEquals(persistentListOf("bitcoin", "btc"), source.hashtags)
assertEquals(persistentListOf("abc"), source.authors)
assertEquals(persistentListOf(1, 6), source.kinds)
}
@Test
fun defaultFeedsAreValid() {
val defaults = defaultFeeds()