diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt index 3b5a8d622..258e52c8d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandDeserializer.kt @@ -24,7 +24,6 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import com.fasterxml.jackson.databind.node.ObjectNode import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer @@ -52,9 +51,9 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { val filters = mutableListOf() while (jp.nextToken() != JsonToken.END_ARRAY) { - val filterObj: ObjectNode = jp.codec.readTree(jp) - val filter = ManualFilterDeserializer.fromJson(filterObj) - filters.add(filter) + // currentToken is now START_OBJECT for each filter; + // the streaming parser consumes through END_OBJECT. + filters.add(ManualFilterDeserializer.fromJson(jp)) } ReqCmd( @@ -68,9 +67,7 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { val filters = mutableListOf() while (jp.nextToken() != JsonToken.END_ARRAY) { - val filterObj: ObjectNode = jp.codec.readTree(jp) - val filter = ManualFilterDeserializer.fromJson(filterObj) - filters.add(filter) + filters.add(ManualFilterDeserializer.fromJson(jp)) } CountCmd( @@ -102,9 +99,8 @@ class CommandDeserializer : StdDeserializer(Command::class.java) { NegOpenCmd.LABEL -> { val subId = jp.nextTextValue() - jp.nextToken() - val filterObj: ObjectNode = jp.codec.readTree(jp) - val filter = ManualFilterDeserializer.fromJson(filterObj) + jp.nextToken() // advance to filter's START_OBJECT + val filter = ManualFilterDeserializer.fromJson(jp) val initialMessage = jp.nextTextValue() NegOpenCmd( diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt index 05081abdd..cdab3a9cd 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.node.ObjectNode @@ -32,11 +33,149 @@ class FilterDeserializer : StdDeserializer(Filter::class.java) { override fun deserialize( jp: JsonParser, ctxt: DeserializationContext, - ): Filter = ManualFilterDeserializer.fromJson(jp.codec.readTree(jp)) + ): Filter = ManualFilterDeserializer.fromJson(jp) } class ManualFilterDeserializer { companion object { + /** + * Streaming filter parser. Reads field-by-field off [jp] without + * materializing an intermediate `JsonNode` tree — same shape as + * [com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer], + * which is what makes high-fan-out REQ traffic cheap on the + * relay-inbound path. + * + * Caller must position the parser so [jp.currentToken] is the + * `START_OBJECT` opening the filter. On return, [jp.currentToken] + * is the matching `END_OBJECT`. + * + * Tolerant by design — invalid array entries (wrong type, JSON + * null) are silently dropped, mirroring the + * `mapNotNull { it.asTextOrNull() }` behavior of the tree-based + * overload below. Unknown top-level fields are skipped via + * [JsonParser.skipChildren]. + */ + fun fromJson(jp: JsonParser): Filter { + var ids: MutableList? = null + var authors: MutableList? = null + var kinds: MutableList? = null + var tags: MutableMap>? = null + var tagsAll: MutableMap>? = null + var since: Long? = null + var until: Long? = null + var limit: Int? = null + var search: String? = null + + while (jp.nextToken() != JsonToken.END_OBJECT) { + val name = jp.currentName() + jp.nextToken() // advance to value + when { + name == "ids" -> { + ids = readStringArray(jp) + } + + name == "authors" -> { + authors = readStringArray(jp) + } + + name == "kinds" -> { + kinds = readIntArray(jp) + } + + name == "since" -> { + if (jp.currentToken != JsonToken.VALUE_NULL) since = jp.longValue + } + + name == "until" -> { + if (jp.currentToken != JsonToken.VALUE_NULL) until = jp.longValue + } + + name == "limit" -> { + if (jp.currentToken != JsonToken.VALUE_NULL) limit = jp.intValue + } + + name == "search" -> { + if (jp.currentToken != JsonToken.VALUE_NULL) search = jp.text + } + + name.length > 1 && name[0] == '#' -> { + val map = tags ?: mutableMapOf>().also { tags = it } + map[name.substring(1)] = readStringArray(jp) + } + + name.length > 1 && name[0] == '&' -> { + val map = tagsAll ?: mutableMapOf>().also { tagsAll = it } + map[name.substring(1)] = readStringArray(jp) + } + + else -> { + jp.skipChildren() + } + } + } + + return Filter( + ids = ids, + authors = authors, + kinds = kinds, + tags = tags, + tagsAll = tagsAll, + since = since, + until = until, + limit = limit, + search = search, + ) + } + + /** + * Reads a string array off [jp]. Drops non-string entries and + * JSON nulls — matches the `mapNotNull { it.asTextOrNull() }` + * tolerance of the tree-based path. Returns an empty list when + * the value is anything other than a `START_ARRAY` (incl. + * `null`), so callers don't have to special-case that. + */ + private fun readStringArray(jp: JsonParser): MutableList { + val out = mutableListOf() + if (jp.currentToken == JsonToken.START_ARRAY) { + while (jp.nextToken() != JsonToken.END_ARRAY) { + if (jp.currentToken == JsonToken.VALUE_STRING) { + out.add(jp.text) + } else if (jp.currentToken == JsonToken.START_OBJECT || jp.currentToken == JsonToken.START_ARRAY) { + jp.skipChildren() + } + } + } else if (jp.currentToken == JsonToken.START_OBJECT) { + jp.skipChildren() + } + return out + } + + /** + * Reads an int array off [jp]. Same tolerance rules as + * [readStringArray] — non-numeric entries are dropped. + */ + private fun readIntArray(jp: JsonParser): MutableList { + val out = mutableListOf() + if (jp.currentToken == JsonToken.START_ARRAY) { + while (jp.nextToken() != JsonToken.END_ARRAY) { + when (jp.currentToken) { + JsonToken.VALUE_NUMBER_INT, JsonToken.VALUE_NUMBER_FLOAT -> out.add(jp.intValue) + JsonToken.START_OBJECT, JsonToken.START_ARRAY -> jp.skipChildren() + else -> Unit + } + } + } else if (jp.currentToken == JsonToken.START_OBJECT) { + jp.skipChildren() + } + return out + } + + /** + * Tree-based overload kept for callers that already have an + * `ObjectNode` in hand (e.g. cross-format adapters). New code on + * the relay-inbound path should use the streaming overload — + * this one materializes the full filter tree first. + */ fun fromJson(jsonObject: ObjectNode): Filter { val tagsIn = mutableListOf() jsonObject.fieldNames().forEach {