diff --git a/CHANGELOG.md b/CHANGELOG.md index b50ebd1b..0102db59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## 1.5.0-wip + +* [FIX] ignore empty query segments before applying `parameterLimit` so delimiters do not consume the limit budget +* [FIX] skip empty keys during decode to match `qs` +* [FIX] enforce comma list limits with truncation or throwing, including duplicate key accumulation +* [FIX] correct UTF-16 surrogate encoding and prevent segment-boundary splits in `Utils.encode` +* [FIX] decode `ByteArray`/`ByteBuffer` values via charset even when `encode=false` +* [FIX] ensure `FunctionFilter` results still pass through date serialization and COMMA temporal normalization +* [FIX] replace undefined holes during list merges and normalize when `parseLists=false` +* [FIX] detect cycles introduced by filters during encoding +* [FIX] append scalars to overflow maps during merge to preserve list-limit semantics +* [FIX] preserve overflow indices/maxIndex when merging `OverflowMap` into `null` targets +* [FIX] skip `Undefined` values when appending iterables into `OverflowMap` via `combine` +* [FIX] preserve overflow semantics in merge/combine (overflow sources, negative `listLimit`) +* [FIX] COMMA list encoding honors ByteArray/ByteBuffer decoding when `encode=false` +* [CHORE] refactor encode/merge internals to stack-based traversal for deep-nesting safety +* [CHORE] expand tests for empty segments, comma limits, surrogates, byte buffers, filter date normalization, and overflow edge cases + ## 1.4.4 * [CHORE] update Kotlin to 2.3.10 diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Decoder.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Decoder.kt index b99a69b3..65a69c1b 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Decoder.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Decoder.kt @@ -21,16 +21,28 @@ internal object Decoder { private fun parseListValue(value: Any?, options: DecodeOptions, currentListLength: Int): Any? { if (value is String && value.isNotEmpty() && options.comma && value.contains(',')) { val splitVal = value.split(',') - if (options.throwOnLimitExceeded && splitVal.size > options.listLimit) { - throw IndexOutOfBoundsException( - "List limit exceeded. " + - "Only ${options.listLimit} element${if (options.listLimit == 1) "" else "s"} allowed in a list." - ) + if (options.listLimit >= 0) { + val remaining = options.listLimit - currentListLength + if ( + options.throwOnLimitExceeded && + (currentListLength + splitVal.size) > options.listLimit + ) { + throw IndexOutOfBoundsException( + "List limit exceeded. " + + "Only ${options.listLimit} element${if (options.listLimit == 1) "" else "s"} allowed in a list." + ) + } + if (remaining <= 0) return emptyList() + return if (splitVal.size <= remaining) splitVal else splitVal.subList(0, remaining) } return splitVal } - if (options.throwOnLimitExceeded && currentListLength >= options.listLimit) { + if ( + options.listLimit >= 0 && + options.throwOnLimitExceeded && + currentListLength >= options.listLimit + ) { throw IndexOutOfBoundsException( "List limit exceeded. " + "Only ${options.listLimit} element${if (options.listLimit == 1) "" else "s"} allowed in a list." @@ -68,13 +80,13 @@ internal object Decoder { throw IllegalArgumentException("Parameter limit must be a positive integer.") } + val allParts: List = options.delimiter.split(cleanStr) val parts = if (limit != null) { - val allParts: List = options.delimiter.split(cleanStr) val takeCount: Int = if (options.throwOnLimitExceeded) limit + 1 else limit - allParts.take(takeCount) + allParts.asSequence().filter { it.isNotEmpty() }.take(takeCount).toList() } else { - options.delimiter.split(cleanStr) + allParts.asSequence().filter { it.isNotEmpty() }.toList() } if (options.throwOnLimitExceeded && limit != null && parts.size > limit) { @@ -105,6 +117,7 @@ internal object Decoder { if (i == skipIndex) continue val part = parts[i] + if (part.isEmpty()) continue val bracketEqualsPos = part.indexOf("]=") val pos = if (bracketEqualsPos == -1) part.indexOf('=') else bracketEqualsPos + 1 @@ -131,6 +144,7 @@ internal object Decoder { options.decodeValue(v as String?, charset) } } + if (key.isEmpty()) continue if ( value != null && diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt index 6031dfe4..cd0aa7dd 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Encoder.kt @@ -5,23 +5,62 @@ import io.github.techouse.qskotlin.enums.Formatter import io.github.techouse.qskotlin.enums.ListFormat import io.github.techouse.qskotlin.enums.ListFormatGenerator import io.github.techouse.qskotlin.models.* +import java.nio.ByteBuffer import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.time.Instant import java.time.LocalDateTime -import java.util.WeakHashMap +import java.util.Collections +import java.util.IdentityHashMap /** A helper object for encoding data into a query string format. */ internal object Encoder { - // Top-level unique token - private val SENTINEL = Any() + // Traversal phases for the encoder's explicit stack. + private enum class Phase { + START, + ITERATE, + WAIT_CHILD, + } + + // Mutable traversal frame; kept local to avoid leaking internal state. + private class Frame( + var obj: Any?, + val undefined: Boolean, + val prefix: String, + val generateArrayPrefix: ListFormatGenerator, + val commaRoundTrip: Boolean, + val commaCompactNulls: Boolean, + val allowEmptyLists: Boolean, + val strictNullHandling: Boolean, + val skipNulls: Boolean, + val encodeDotInKeys: Boolean, + val encoder: ValueEncoder?, + val serializeDate: DateSerializer?, + val sort: Sorter?, + val filter: Filter?, + val allowDots: Boolean, + val format: Format, + val formatter: Formatter, + val encodeValuesOnly: Boolean, + val charset: Charset, + val addQueryPrefix: Boolean, + var phase: Phase = Phase.START, + var values: MutableList = mutableListOf(), + var objKeys: List = emptyList(), + var index: Int = 0, + var adjustedPrefix: String = "", + var effectiveCommaLength: Int? = null, + var iterableList: List? = null, + var tracked: Boolean = false, + var trackedObject: Any? = null, + ) /** * Encodes the given data into a query string format. * * @param data The data to encode; can be any type. * @param undefined If true, will not encode undefined values. - * @param sideChannel A mutable map for tracking cyclic references. + * @param sideChannel Reserved for compatibility; unused (cycle tracking is internal). * @param prefix An optional prefix for the encoded string. * @param generateArrayPrefix A generator for array prefixes. * @param commaRoundTrip If true, uses comma for array encoding. @@ -44,7 +83,7 @@ internal object Encoder { fun encode( data: Any?, undefined: Boolean, - sideChannel: MutableMap, + @Suppress("UNUSED_PARAMETER") sideChannel: MutableMap, prefix: String? = null, generateArrayPrefix: ListFormatGenerator? = null, commaRoundTrip: Boolean? = null, @@ -64,249 +103,371 @@ internal object Encoder { charset: Charset = StandardCharsets.UTF_8, addQueryPrefix: Boolean = false, ): Any { - val prefix: String = prefix ?: if (addQueryPrefix) "?" else "" - val generateArrayPrefix: ListFormatGenerator = - generateArrayPrefix ?: ListFormat.INDICES.generator - val isCommaGenerator = generateArrayPrefix == ListFormat.COMMA.generator - val commaRoundTrip: Boolean = commaRoundTrip ?: isCommaGenerator - val compactNulls = commaCompactNulls && isCommaGenerator - - var obj: Any? = data - - val objWrapper = data?.let { WeakWrapper(it) } - var tmpSc: MutableMap? = sideChannel - var step = 0 - var findFlag = false - - // Walk ancestors - while (!findFlag) { - @Suppress("UNCHECKED_CAST") - tmpSc = tmpSc?.get(SENTINEL) as? MutableMap ?: break - step++ - val pos: Int? = objWrapper?.let { tmpSc[it] as? Int } - if (pos != null) { - if (pos == step) { - throw IndexOutOfBoundsException("Cyclic object value") - } else { - findFlag = true - } - } - if (tmpSc[SENTINEL] == null) { - step = 0 - } - } - - if (filter is FunctionFilter) { - obj = filter.function(prefix, obj) - } else if (obj is LocalDateTime) { - obj = - when (serializeDate) { - null -> obj.toString() // Default ISO format - else -> serializeDate(obj) - } - } else if (generateArrayPrefix == ListFormat.COMMA.generator && obj is Iterable<*>) { - obj = - obj.map { value -> - when (value) { - is Instant -> value.toString() - is LocalDateTime -> serializeDate?.invoke(value) ?: value.toString() - else -> value - } - } - } - - if (!undefined && obj == null) { - if (strictNullHandling) - return when { - (encoder != null && !encodeValuesOnly) -> encoder(prefix, charset, format) - else -> prefix - } - - obj = "" - } - - if (Utils.isNonNullishPrimitive(obj, skipNulls) || obj is ByteArray) - return when { - (encoder != null) -> { - val keyValue: String = - if (encodeValuesOnly) prefix else encoder(prefix, null, null) - - "${formatter(keyValue)}=${formatter(encoder(obj, null, null))}" - } + val prefixValue: String = prefix ?: if (addQueryPrefix) "?" else "" + val generator: ListFormatGenerator = generateArrayPrefix ?: ListFormat.INDICES.generator + val isCommaGenerator = generator == ListFormat.COMMA.generator + val effectiveCommaRoundTrip: Boolean = commaRoundTrip ?: isCommaGenerator + + // Use identity-based tracking for the current traversal path to detect cycles. + val seen = Collections.newSetFromMap(IdentityHashMap()) + + val stack = ArrayDeque() + stack.add( + Frame( + obj = data, + undefined = undefined, + prefix = prefixValue, + generateArrayPrefix = generator, + commaRoundTrip = effectiveCommaRoundTrip, + commaCompactNulls = commaCompactNulls, + allowEmptyLists = allowEmptyLists, + strictNullHandling = strictNullHandling, + skipNulls = skipNulls, + encodeDotInKeys = encodeDotInKeys, + encoder = encoder, + serializeDate = serializeDate, + sort = sort, + filter = filter, + allowDots = allowDots, + format = format, + formatter = formatter, + encodeValuesOnly = encodeValuesOnly, + charset = charset, + addQueryPrefix = addQueryPrefix, + ) + ) - else -> "${formatter(prefix)}=${formatter(obj.toString())}" - } + var lastResult: Any? = null - val values = mutableListOf() + while (stack.isNotEmpty()) { + val frame = stack.last() - if (undefined) { - return values - } + when (frame.phase) { + Phase.START -> { + var obj: Any? = frame.obj - var effectiveCommaLength: Int? = null - - val objKeys: List = - when { - isCommaGenerator && obj is Iterable<*> -> { - // materialize once for reuse - val items = obj.toList() - val filtered = if (compactNulls) items.filterNotNull() else items + when (val f = frame.filter) { + is FunctionFilter -> { + obj = f.function(frame.prefix, obj) + } + else -> Unit + } - effectiveCommaLength = filtered.size + if (obj is LocalDateTime) { + obj = frame.serializeDate?.invoke(obj) ?: obj.toString() + } else if (isCommaGenerator && obj is Iterable<*>) { + obj = + obj.map { value -> + when (value) { + is Instant -> value.toString() + is LocalDateTime -> + frame.serializeDate?.invoke(value) ?: value.toString() + else -> value + } + } + } - val joinSource = - if (encodeValuesOnly && encoder != null) { - filtered.map { el -> - el?.let { encoder(it.toString(), null, null) } ?: "" + if (!frame.undefined && obj == null) { + if (frame.strictNullHandling) { + val keyOnly = + if (frame.encoder != null && !frame.encodeValuesOnly) { + frame.encoder.invoke(frame.prefix, frame.charset, frame.format) + } else { + frame.prefix + } + if (frame.tracked) { + frame.trackedObject?.let { seen.remove(it) } } - } else { - filtered.map { el -> el?.toString() ?: "" } + stack.removeLast() + lastResult = keyOnly + continue } - - if (joinSource.isNotEmpty()) { - val joined = joinSource.joinToString(",") - - listOf(mapOf("value" to joined.ifEmpty { null })) - } else { - listOf(mapOf("value" to Undefined.Companion())) + obj = "" } - } - filter is IterableFilter -> filter.iterable.toList() + val trackObject = obj is Map<*, *> || obj is Array<*> || obj is Iterable<*> - else -> { - val keys: Iterable = - when (obj) { - is Map<*, *> -> obj.keys - is List<*> -> obj.indices - is Array<*> -> obj.indices - is Iterable<*> -> obj.mapIndexed { index, _ -> index } - else -> emptyList() + if (trackObject) { + val objRef = obj + if (seen.contains(objRef)) { + throw IndexOutOfBoundsException("Cyclic object value") } - - if (sort != null) { - keys.toMutableList().apply { sortWith(sort) } - } else { - keys.toList() + seen.add(objRef) + frame.tracked = true + frame.trackedObject = objRef } - } - } - val encodedPrefix: String = if (encodeDotInKeys) prefix.replace(".", "%2E") else prefix - - val adjustedPrefix: String = - if ( - commaRoundTrip && - obj is Iterable<*> && - (if (isCommaGenerator && effectiveCommaLength != null) { - effectiveCommaLength == 1 - } else { - obj.count() == 1 - }) - ) - "$encodedPrefix[]" - else encodedPrefix + if ( + Utils.isNonNullishPrimitive(obj, frame.skipNulls) || + obj is ByteArray || + obj is ByteBuffer + ) { + val fragment = + if (frame.encoder != null) { + val keyValue = + if (frame.encodeValuesOnly) frame.prefix + else + frame.encoder.invoke( + frame.prefix, + frame.charset, + frame.format, + ) + val encodedValue = + frame.encoder.invoke(obj, frame.charset, frame.format) + "${frame.formatter(keyValue)}=${frame.formatter(encodedValue)}" + } else { + val rawValue = + Utils.bytesToString(obj, frame.charset) ?: obj.toString() + "${frame.formatter(frame.prefix)}=${frame.formatter(rawValue)}" + } - if (allowEmptyLists && obj is Iterable<*> && !obj.iterator().hasNext()) { - return "$adjustedPrefix[]" - } + if (frame.tracked) { + frame.trackedObject?.let { seen.remove(it) } + } + stack.removeLast() + lastResult = fragment + continue + } - for (i: Int in 0 until objKeys.size) { - val key = objKeys[i] - val (value: Any?, valueUndefined: Boolean) = - when { - key is Map<*, *> && key.containsKey("value") && key["value"] !is Undefined -> { - Pair(key["value"], false) + frame.obj = obj + if (frame.undefined) { + if (frame.tracked) { + frame.trackedObject?.let { seen.remove(it) } + } + stack.removeLast() + lastResult = mutableListOf() + continue } - else -> - when (obj) { - is Map<*, *> -> { - Pair(obj[key], !obj.containsKey(key)) - } + if (obj is Iterable<*> && obj !is List<*>) { + frame.iterableList = obj.toList() + } - is Iterable<*> -> { - val index = key as? Int - if (index != null && index >= 0 && index < obj.count()) { - Pair(obj.elementAt(index), false) + val objKeys: List = + when { + isCommaGenerator && obj is Iterable<*> -> { + val items = + when { + obj is List<*> -> obj + frame.iterableList != null -> frame.iterableList!! + else -> obj.toList() + } + + val filtered = + if (frame.commaCompactNulls) items.filterNotNull() else items + + frame.effectiveCommaLength = filtered.size + + val joinSource = + if (frame.encodeValuesOnly && frame.encoder != null) { + filtered.map { el -> + el?.let { + frame.encoder.invoke(it.toString(), null, null) + } ?: "" + } + } else { + filtered.map { el -> + when (el) { + is ByteArray, + is ByteBuffer -> + Utils.bytesToString(el, frame.charset) ?: "" + else -> el?.toString() ?: "" + } + } + } + + if (joinSource.isNotEmpty()) { + val joined = joinSource.joinToString(",") + listOf(mapOf("value" to joined.ifEmpty { null })) } else { - Pair(null, true) + listOf(mapOf("value" to Undefined.Companion())) } } - is Array<*> -> { - val index = key as? Int - if (index != null && index >= 0 && index < obj.size) { - Pair(obj[index], false) + frame.filter is IterableFilter -> { + frame.filter.iterable.toList() + } + + else -> { + val keys: Iterable = + when (obj) { + is Map<*, *> -> obj.keys + is List<*> -> obj.indices + is Array<*> -> obj.indices + is Iterable<*> -> { + val list = frame.iterableList ?: obj.toList() + list.indices + } + else -> emptyList() + } + + if (frame.sort != null) { + keys.toMutableList().apply { sortWith(frame.sort) } } else { - Pair(null, true) + keys.toList() } } + } - else -> { - Pair(null, true) // Handle unsupported object types gracefully + val encodedPrefix: String = + if (frame.encodeDotInKeys) frame.prefix.replace(".", "%2E") + else frame.prefix + + val adjustedPrefix: String = + if ( + frame.commaRoundTrip && + obj is Iterable<*> && + (if (isCommaGenerator && frame.effectiveCommaLength != null) { + frame.effectiveCommaLength == 1 + } else { + val count = + when (obj) { + is Collection<*> -> obj.size + else -> frame.iterableList?.size ?: obj.count() + } + count == 1 + }) + ) + "$encodedPrefix[]" + else encodedPrefix + + val iterableEmpty = + if (obj is Iterable<*>) { + when (obj) { + is Collection<*> -> obj.isEmpty() + else -> frame.iterableList?.isEmpty() ?: !obj.iterator().hasNext() } + } else { + false } - } - if (skipNulls && value == null) { - continue - } + if (frame.allowEmptyLists && obj is Iterable<*> && iterableEmpty) { + if (frame.tracked) { + frame.trackedObject?.let { seen.remove(it) } + } + stack.removeLast() + lastResult = "$adjustedPrefix[]" + continue + } - val encodedKey: String = - if (allowDots && encodeDotInKeys) key.toString().replace(".", "%2E") - else key.toString() + frame.objKeys = objKeys + frame.adjustedPrefix = adjustedPrefix + frame.phase = Phase.ITERATE + continue + } - val keyPrefix: String = - if (obj is Iterable<*>) generateArrayPrefix(adjustedPrefix, encodedKey) - else "$adjustedPrefix${if (allowDots) ".$encodedKey" else "[$encodedKey]"}" + Phase.ITERATE -> { + if (frame.index >= frame.objKeys.size) { + if (frame.tracked) { + frame.trackedObject?.let { seen.remove(it) } + } + stack.removeLast() + lastResult = frame.values + continue + } - // Record the current container in this frame so children can detect cycles. - if (obj is Map<*, *> || obj is Iterable<*>) { - objWrapper?.let { sideChannel[it] = step } - } + val key = frame.objKeys[frame.index++] + val obj = frame.obj - // Create child side-channel and link to the parent - val valueSideChannel = WeakHashMap() - valueSideChannel[SENTINEL] = sideChannel - - val encoded: Any = - encode( - data = value, - undefined = valueUndefined, - prefix = keyPrefix, - generateArrayPrefix = generateArrayPrefix, - commaRoundTrip = commaRoundTrip, - commaCompactNulls = commaCompactNulls, - allowEmptyLists = allowEmptyLists, - strictNullHandling = strictNullHandling, - skipNulls = skipNulls, - encodeDotInKeys = encodeDotInKeys, - encoder = + val (value: Any?, valueUndefined: Boolean) = when { - generateArrayPrefix == ListFormat.COMMA.generator && - encodeValuesOnly && - obj is Iterable<*> -> null - else -> encoder - }, - serializeDate = serializeDate, - filter = filter, - sort = sort, - allowDots = allowDots, - format = format, - formatter = formatter, - encodeValuesOnly = encodeValuesOnly, - charset = charset, - addQueryPrefix = addQueryPrefix, - sideChannel = valueSideChannel, - ) - - when (encoded) { - is Iterable<*> -> values.addAll(encoded) - else -> values.add(encoded) + key is Map<*, *> && + key.containsKey("value") && + key["value"] !is Undefined -> { + Pair(key["value"], false) + } + else -> + when (obj) { + is Map<*, *> -> Pair(obj[key], !obj.containsKey(key)) + is Iterable<*> -> { + val index = key as? Int + val list = + when (obj) { + is List<*> -> obj + else -> frame.iterableList ?: obj.toList() + } + if (index != null && index >= 0 && index < list.size) { + Pair(list[index], false) + } else { + Pair(null, true) + } + } + is Array<*> -> { + val index = key as? Int + if (index != null && index >= 0 && index < obj.size) { + Pair(obj[index], false) + } else { + Pair(null, true) + } + } + else -> Pair(null, true) + } + } + + if (frame.skipNulls && value == null) { + continue + } + + val encodedKey: String = + if (frame.allowDots && frame.encodeDotInKeys) + key.toString().replace(".", "%2E") + else key.toString() + + val keyPrefix: String = + if (obj is Iterable<*>) + frame.generateArrayPrefix(frame.adjustedPrefix, encodedKey) + else + "${frame.adjustedPrefix}${if (frame.allowDots) ".$encodedKey" else "[$encodedKey]"}" + + val childEncoder = + if ( + frame.generateArrayPrefix == ListFormat.COMMA.generator && + frame.encodeValuesOnly && + obj is Iterable<*> + ) + null + else frame.encoder + + frame.phase = Phase.WAIT_CHILD + stack.add( + Frame( + obj = value, + undefined = valueUndefined, + prefix = keyPrefix, + generateArrayPrefix = frame.generateArrayPrefix, + commaRoundTrip = frame.commaRoundTrip, + commaCompactNulls = frame.commaCompactNulls, + allowEmptyLists = frame.allowEmptyLists, + strictNullHandling = frame.strictNullHandling, + skipNulls = frame.skipNulls, + encodeDotInKeys = frame.encodeDotInKeys, + encoder = childEncoder, + serializeDate = frame.serializeDate, + sort = frame.sort, + filter = frame.filter, + allowDots = frame.allowDots, + format = frame.format, + formatter = frame.formatter, + encodeValuesOnly = frame.encodeValuesOnly, + charset = frame.charset, + addQueryPrefix = frame.addQueryPrefix, + ) + ) + continue + } + + Phase.WAIT_CHILD -> { + val encoded = lastResult + when (encoded) { + is Iterable<*> -> frame.values.addAll(encoded) + else -> frame.values.add(encoded) + } + frame.phase = Phase.ITERATE + continue + } } } - return values + return lastResult ?: emptyList() } } diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Utils.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Utils.kt index 45a95014..65dcbcde 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Utils.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Utils.kt @@ -8,6 +8,7 @@ import java.net.URI import java.net.URLDecoder import java.nio.ByteBuffer import java.nio.charset.Charset +import java.nio.charset.CodingErrorAction import java.nio.charset.StandardCharsets import java.time.Instant import java.time.LocalDateTime @@ -17,10 +18,33 @@ import kotlin.collections.ArrayDeque /** A collection of utility methods used by the library. */ internal object Utils { + private enum class MergePhase { + START, + LIST_ITER, + MAP_ITER, + } + + private data class MergeFrame( + var target: Any?, + var source: Any?, + val options: DecodeOptions, + val onResult: (Any?) -> Unit, + var phase: MergePhase = MergePhase.START, + var indexedTarget: MutableMap? = null, + var sourceList: List? = null, + var listIndex: Int = 0, + var targetIsSet: Boolean = false, + var mergeTarget: MutableMap? = null, + var mapIterator: Iterator>? = null, + var overflowMax: Int? = null, + ) + /** - * Merges two objects, where the source object overrides the target object. If the source is a - * Map, it will merge its entries into the target. If the source is an Iterable, it will append - * its items to the target. If the source is a primitive, it will replace the target. + * Merges two objects, where the source object overrides or extends the target object. + * - If the source is a Map, it will merge its entries into the target. + * - If the source is an Iterable, it will append its items to the target. + * - If the source is a primitive, it will combine with the target following qs semantics + * (including OverflowMap append behavior). * * @param target The target object to merge into. * @param source The source object to merge from. @@ -28,181 +52,368 @@ internal object Utils { * @return The merged object. */ fun merge(target: Any?, source: Any?, options: DecodeOptions = DecodeOptions()): Any? { - if (source == null) { - return target + var result: Any? = null + val stack = ArrayDeque() + + stack.add( + MergeFrame( + target = target, + source = source, + options = options, + onResult = { value -> result = value }, + ) + ) + + // Convert an iterable into a stable index->value map for merge operations. + fun toIndexedMap(iterable: Iterable<*>): MutableMap { + val map = java.util.TreeMap() + var i = 0 + for (v in iterable) { + map[i++] = v + } + return map } - if (source !is Map<*, *>) { - return when (target) { - is Iterable<*> -> - when { - target.any { it is Undefined } -> { - val mutableTarget: MutableMap = - target - .withIndex() - .associate { it.index.toString() to it.value } - .toMutableMap() + fun updateOverflowMax(current: Int, key: Any?): Int { + val parsed = + when (key) { + is Int -> key + is Long -> if (key in Int.MIN_VALUE..Int.MAX_VALUE) key.toInt() else null + is String -> key.toIntOrNull() + else -> null + } + return if (parsed == null || parsed < 0) current else maxOf(current, parsed) + } - when (source) { - is Iterable<*> -> - source.forEachIndexed { i, item -> - if (item !is Undefined) { - mutableTarget[i.toString()] = item - } + while (stack.isNotEmpty()) { + val frame = stack.last() + + when (frame.phase) { + MergePhase.START -> { + val currentTarget = frame.target + val currentSource = frame.source + + if (currentSource == null) { + stack.removeLast() + frame.onResult(currentTarget) + continue + } + + if (currentSource !is Map<*, *>) { + when (currentTarget) { + is Iterable<*> -> { + if (currentTarget.any { it is Undefined }) { + val mutableTarget: MutableMap = + currentTarget + .withIndex() + .associate { it.index.toString() to it.value } + .toMutableMap() + + when (currentSource) { + is Iterable<*> -> + currentSource.forEachIndexed { i, item -> + if (item !is Undefined) { + mutableTarget[i.toString()] = item + } + } + + else -> + mutableTarget[mutableTarget.size.toString()] = + currentSource } - else -> mutableTarget[mutableTarget.size.toString()] = source - } + val merged = + when { + !options.parseLists && + mutableTarget.values.any { it is Undefined } -> + mutableTarget.filterValues { it !is Undefined } + currentTarget is Set<*> -> mutableTarget.values.toSet() + else -> mutableTarget.values.toList() + } - when { - !options.parseLists && - mutableTarget.values.any { it is Undefined } -> - mutableTarget.filterValues { it !is Undefined } + stack.removeLast() + frame.onResult(merged) + continue + } - target is Set<*> -> mutableTarget.values.toSet() + if (currentSource is Iterable<*>) { + val targetMaps = + currentTarget.all { it is Map<*, *> || it is Undefined } + val sourceMaps = + currentSource.all { it is Map<*, *> || it is Undefined } + + if (targetMaps && sourceMaps) { + frame.indexedTarget = toIndexedMap(currentTarget) + frame.sourceList = currentSource.toList() + frame.targetIsSet = currentTarget is Set<*> + frame.listIndex = 0 + frame.phase = MergePhase.LIST_ITER + continue + } - else -> mutableTarget.values.toList() + val filtered = currentSource.filterNot { it is Undefined } + val merged = + when (currentTarget) { + is Set<*> -> currentTarget + filtered + is List<*> -> currentTarget + filtered + else -> listOf(currentTarget) + filtered + } + stack.removeLast() + frame.onResult(merged) + continue + } + + val merged = + when (currentTarget) { + is Set<*> -> currentTarget + currentSource + is List<*> -> currentTarget + currentSource + else -> listOf(currentTarget, currentSource) + } + stack.removeLast() + frame.onResult(merged) + continue } - } - else -> - when (source) { - is Iterable<*> -> - when { - target.all { it is Map<*, *> || it is Undefined } && - source.all { it is Map<*, *> || it is Undefined } -> { - val mutableTarget: MutableMap = - target - .withIndex() - .associate { it.index to it.value } - .toSortedMap() - - source.forEachIndexed { i, item -> - mutableTarget[i] = - when { - mutableTarget.containsKey(i) -> - merge(mutableTarget[i], item, options) - else -> item - } - } + is Map<*, *> -> { + if (currentTarget is OverflowMap && currentSource !is Iterable<*>) { + val newIndex = currentTarget.maxIndex + 1 + currentTarget[newIndex.toString()] = currentSource + currentTarget.maxIndex = newIndex + stack.removeLast() + frame.onResult(currentTarget) + continue + } + if (currentTarget is OverflowMap && currentSource is Iterable<*>) { + var newIndex = currentTarget.maxIndex + for (item in currentSource) { + if (item is Undefined) continue + newIndex += 1 + currentTarget[newIndex.toString()] = item + } + currentTarget.maxIndex = newIndex + stack.removeLast() + frame.onResult(currentTarget) + continue + } + val mutableTarget = currentTarget.toMutableMap() - when (target) { - is Set<*> -> mutableTarget.values.toSet() - else -> mutableTarget.values.toList() + when (currentSource) { + is Iterable<*> -> { + currentSource.forEachIndexed { i, item -> + if (item !is Undefined) { + mutableTarget[i.toString()] = item } } - - else -> - when (target) { - is Set<*> -> - target + source.filterNot { it is Undefined } - is List<*> -> - target + source.filterNot { it is Undefined } - else -> - listOf(target) + - source.filterNot { it is Undefined } - } } + is Undefined -> { + // ignore + } + else -> { + val k = currentSource.toString() + if (k.isNotEmpty()) { + mutableTarget[k] = true + } + } + } - else -> - when (target) { - is Set<*> -> target + source - is List<*> -> target + source - else -> listOf(target, source) + stack.removeLast() + frame.onResult(mutableTarget) + continue + } + + else -> { + val merged = + when (currentSource) { + is Iterable<*> -> + listOf(currentTarget) + + currentSource.filterNot { it is Undefined } + else -> listOf(currentTarget, currentSource) } + stack.removeLast() + frame.onResult(merged) + continue } + } } - is Map<*, *> -> { - val mutableTarget = target.toMutableMap() + // Source is a Map + if (currentTarget == null || currentTarget !is Map<*, *>) { + if (currentTarget is Iterable<*>) { + val mutableTarget: MutableMap = + currentTarget + .withIndex() + .associate { it.index.toString() to it.value } + .filterValues { it !is Undefined } + .toMutableMap() - when (source) { - is Iterable<*> -> { - source.forEachIndexed { i, item -> - if (item !is Undefined) { - mutableTarget[i.toString()] = item - } + @Suppress("UNCHECKED_CAST") + (currentSource as Map).forEach { (key, value) -> + mutableTarget[key.toString()] = value } + stack.removeLast() + frame.onResult(mutableTarget) + continue } - is Undefined -> { - // ignore - } - else -> { - val k = source.toString() - if (k.isNotEmpty()) { - mutableTarget[k] = true + + if (currentSource is OverflowMap) { + val sourceMax = currentSource.maxIndex + val resultMap = OverflowMap() + val shift = if (currentTarget != null) 1 else 0 + if (currentTarget != null) { + resultMap["0"] = currentTarget + } + for ((key, value) in currentSource) { + val keyStr = key + val oldIndex = keyStr.toIntOrNull() + if (oldIndex == null) { + resultMap[keyStr] = value + } else { + resultMap[(oldIndex + shift).toString()] = value + } } + resultMap.maxIndex = sourceMax + shift + stack.removeLast() + frame.onResult(resultMap) + continue } - } - - mutableTarget - } - else -> - when (source) { - is Iterable<*> -> listOf(target) + source.filterNot { it is Undefined } - else -> listOf(target, source) + val mutableTarget = listOfNotNull(currentTarget).toMutableList() + when (currentSource) { + is Iterable<*> -> + mutableTarget.addAll( + (currentSource as Iterable<*>) + .filterNot { it is Undefined } + .toList() + ) + else -> mutableTarget.add(currentSource) + } + stack.removeLast() + frame.onResult(mutableTarget) + continue } - } - } - if (target == null || target !is Map<*, *>) { - return when (target) { - is Iterable<*> -> { - val mutableTarget: MutableMap = - target - .withIndex() - .associate { it.index.toString() to it.value } - .filterValues { it !is Undefined } - .toMutableMap() + @Suppress("UNCHECKED_CAST") + val mergeTarget: MutableMap = + when { + currentTarget is Iterable<*> && currentSource !is Iterable<*> -> + currentTarget + .withIndex() + .associate { it.index.toString() to it.value } + .filterValues { it !is Undefined } + .toMutableMap() as MutableMap + currentTarget is OverflowMap -> + OverflowMap().apply { + putAll(currentTarget) + maxIndex = currentTarget.maxIndex + } as MutableMap + else -> (currentTarget as Map).toMutableMap() + } + frame.mergeTarget = mergeTarget @Suppress("UNCHECKED_CAST") - (source as Map).forEach { (key, value) -> - mutableTarget[key.toString()] = value - } - mutableTarget + frame.mapIterator = (currentSource as Map).entries.iterator() + frame.overflowMax = (mergeTarget as? OverflowMap)?.maxIndex + frame.phase = MergePhase.MAP_ITER + continue } - else -> { - val mutableTarget = listOfNotNull(target).toMutableList() + MergePhase.MAP_ITER -> { + if (frame.mapIterator?.hasNext() == true) { + val entry = frame.mapIterator!!.next() + val key = entry.key - when (source) { - is Iterable<*> -> - mutableTarget.addAll( - (source as Iterable<*>).filterNot { it is Undefined }.toList() + if (frame.overflowMax != null) { + frame.overflowMax = updateOverflowMax(frame.overflowMax!!, key) + } + + val mergeTarget = frame.mergeTarget!! + if (mergeTarget.containsKey(key)) { + val childTarget = mergeTarget[key] + stack.add( + MergeFrame( + target = childTarget, + source = entry.value, + options = frame.options, + onResult = { value -> mergeTarget[key] = value }, + ) ) + continue + } - else -> mutableTarget.add(source) + mergeTarget[key] = entry.value + continue + } + + if (frame.overflowMax != null && frame.mergeTarget is OverflowMap) { + (frame.mergeTarget as OverflowMap).maxIndex = frame.overflowMax!! } - mutableTarget + stack.removeLast() + frame.onResult(frame.mergeTarget!!) + continue } - } - } - @Suppress("UNCHECKED_CAST") - val mergeTarget: MutableMap = - when { - target is Iterable<*> && source !is Iterable<*> -> - target - .withIndex() - .associate { it.index.toString() to it.value } - .filterValues { it !is Undefined } - .toMutableMap() - else -> (target as Map).toMutableMap() - } + MergePhase.LIST_ITER -> { + if (frame.listIndex >= frame.sourceList!!.size) { + if ( + frame.options.parseLists == false && + frame.indexedTarget!!.values.any { it is Undefined } + ) { + val normalized = mutableMapOf() + for ((index, value) in frame.indexedTarget!!) { + if (value !is Undefined) { + normalized[index.toString()] = value + } + } + stack.removeLast() + frame.onResult(normalized) + continue + } - @Suppress("UNCHECKED_CAST") - (source as Map).forEach { (key, value) -> - mergeTarget[key] = - if (mergeTarget.containsKey(key)) { - merge(mergeTarget[key], value, options) - } else { - value + val merged = + if (frame.targetIsSet) { + frame.indexedTarget!!.values.toSet() + } else { + frame.indexedTarget!!.values.toList() + } + stack.removeLast() + frame.onResult(merged) + continue + } + + val idx = frame.listIndex++ + val item = frame.sourceList!![idx] + val indexedTarget = frame.indexedTarget!! + + if (indexedTarget.containsKey(idx)) { + val childTarget = indexedTarget[idx] + if (childTarget is Undefined) { + if (item !is Undefined) { + indexedTarget[idx] = item + } + continue + } + if (item is Undefined) { + continue + } + stack.add( + MergeFrame( + target = childTarget, + source = item, + options = frame.options, + onResult = { value -> indexedTarget[idx] = value }, + ) + ) + continue + } + + indexedTarget[idx] = item + continue } + } } - return mergeTarget + return result } /** @@ -322,6 +533,35 @@ internal object Utils { /** The maximum length of a segment to encode in a single pass. */ private const val SEGMENT_LIMIT = 1024 + /** Decode raw bytes to a String using the supplied charset, replacing malformed input. */ + private fun decodeBytes(bytes: ByteArray, charset: Charset): String = + if (charset == StandardCharsets.UTF_8) { + val decoder = + charset + .newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + decoder.decode(ByteBuffer.wrap(bytes)).toString() + } else { + String(bytes, charset) + } + + /** Extract bytes from a ByteBuffer without mutating its position. */ + private fun byteBufferToArray(buffer: ByteBuffer): ByteArray { + val dup = buffer.duplicate() + val out = ByteArray(dup.remaining()) + dup.get(out) + return out + } + + /** Coerce ByteArray/ByteBuffer to String using the supplied charset; null otherwise. */ + internal fun bytesToString(value: Any?, charset: Charset): String? = + when (value) { + is ByteArray -> decodeBytes(value, charset) + is ByteBuffer -> decodeBytes(byteBufferToArray(value), charset) + else -> null + } + /** * Encodes a value into a URL-encoded string. * @@ -345,8 +585,8 @@ internal object Utils { val str = when (value) { - is ByteBuffer -> String(value.array(), charset) - is ByteArray -> String(value, charset) + is ByteBuffer -> bytesToString(value, charset) + is ByteArray -> bytesToString(value, charset) else -> value?.toString() } @@ -367,12 +607,16 @@ internal object Utils { var j = 0 while (j < str.length) { - val segment = - if (str.length >= SEGMENT_LIMIT) { - str.substring(j, minOf(j + SEGMENT_LIMIT, str.length)) - } else { - str + var end = minOf(j + SEGMENT_LIMIT, str.length) + if (end < str.length) { + val last = str[end - 1] + val next = str[end] + // Avoid splitting a surrogate pair across segment boundaries. + if (last in '\uD800'..'\uDBFF' && next in '\uDC00'..'\uDFFF') { + end -= 1 // keep surrogate pair together } + } + val segment = str.substring(j, end) var i = 0 while (i < segment.length) { @@ -415,7 +659,29 @@ internal object Utils { continue } - c !in 0xD800..<0xE000 -> { // 3 bytes + c in 0xD800..0xDBFF -> { // high surrogate + if (i + 1 < segment.length) { + val nextC = segment[i + 1].code + if (nextC in 0xDC00..0xDFFF) { + val codePoint = + 0x10000 + (((c - 0xD800) shl 10) or (nextC - 0xDC00)) + buffer.append(HexTable[0xF0 or (codePoint shr 18)]) + buffer.append(HexTable[0x80 or ((codePoint shr 12) and 0x3F)]) + buffer.append(HexTable[0x80 or ((codePoint shr 6) and 0x3F)]) + buffer.append(HexTable[0x80 or (codePoint and 0x3F)]) + i += 2 + continue + } + } + // Lone high surrogate: encode code unit as 3-byte sequence. + buffer.append(HexTable[0xE0 or (c shr 12)]) + buffer.append(HexTable[0x80 or ((c shr 6) and 0x3F)]) + buffer.append(HexTable[0x80 or (c and 0x3F)]) + i++ + continue + } + + c in 0xDC00..0xDFFF -> { // lone low surrogate buffer.append(HexTable[0xE0 or (c shr 12)]) buffer.append(HexTable[0x80 or ((c shr 6) and 0x3F)]) buffer.append(HexTable[0x80 or (c and 0x3F)]) @@ -423,20 +689,17 @@ internal object Utils { continue } - else -> { // 4 bytes (surrogate pair) - val nextC = if (i + 1 < segment.length) segment[i + 1].code else 0 - val codePoint = 0x10000 + (((c and 0x3FF) shl 10) or (nextC and 0x3FF)) - buffer.append(HexTable[0xF0 or (codePoint shr 18)]) - buffer.append(HexTable[0x80 or ((codePoint shr 12) and 0x3F)]) - buffer.append(HexTable[0x80 or ((codePoint shr 6) and 0x3F)]) - buffer.append(HexTable[0x80 or (codePoint and 0x3F)]) - i += 2 // Skip the next character as it's part of the surrogate pair + else -> { // 3 bytes + buffer.append(HexTable[0xE0 or (c shr 12)]) + buffer.append(HexTable[0x80 or ((c shr 6) and 0x3F)]) + buffer.append(HexTable[0x80 or (c and 0x3F)]) + i++ continue } } } - j += SEGMENT_LIMIT + j = end } return buffer.toString() @@ -574,8 +837,17 @@ internal object Utils { fun combine(a: Any?, b: Any?, limit: Int): Any? { // If 'a' is already an overflow object, add to it if (a is OverflowMap) { - val newIndex = a.maxIndex + 1 - a[newIndex.toString()] = b + var newIndex = a.maxIndex + if (b is Iterable<*>) { + for (item in b) { + if (item is Undefined) continue + newIndex += 1 + a[newIndex.toString()] = item + } + } else { + newIndex += 1 + a[newIndex.toString()] = b + } a.maxIndex = newIndex return a } @@ -594,7 +866,7 @@ internal object Utils { else -> result.add(b) } - if (result.size > limit) { + if (limit >= 0 && result.size > limit) { val map = OverflowMap() result.forEachIndexed { index, item -> map[index.toString()] = item } map.maxIndex = result.size - 1 diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/DecodeSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/DecodeSpec.kt index a90129e4..a1dd68b6 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/DecodeSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/DecodeSpec.kt @@ -105,6 +105,13 @@ class DecodeSpec : ) } + it("ignores empty segments and empty keys") { + decode("a=b&&c=d") shouldBe mapOf("a" to "b", "c" to "d") + decode("a=b&") shouldBe mapOf("a" to "b") + decode("=a") shouldBe emptyMap() + decode("a=b&=c&d=e") shouldBe mapOf("a" to "b", "d" to "e") + } + it("comma: false") { decode("a[]=b&a[]=c") shouldBe mapOf("a" to listOf("b", "c")) decode("a[0]=b&a[1]=c") shouldBe mapOf("a" to listOf("b", "c")) @@ -132,6 +139,36 @@ class DecodeSpec : .message shouldBe "List limit exceeded. Only 3 elements allowed in a list." } + it("comma: true truncates when list limit exceeded without throwing") { + decode( + "a=b,c", + DecodeOptions(comma = true, throwOnLimitExceeded = false, listLimit = 1), + ) shouldBe mapOf("a" to listOf("b")) + } + + it("comma: true ignores negative list limit") { + decode( + "a=b,c", + DecodeOptions(comma = true, throwOnLimitExceeded = false, listLimit = -1), + ) shouldBe mapOf("a" to listOf("b", "c")) + } + + it("comma: true counts existing list length across duplicates") { + shouldThrow { + decode( + "a=b,c&a=d", + DecodeOptions(comma = true, throwOnLimitExceeded = true, listLimit = 2), + ) + } + } + + it("comma: true keeps existing list when duplicate arrives at list limit") { + decode( + "a=b,c&a=d,e", + DecodeOptions(comma = true, throwOnLimitExceeded = false, listLimit = 2), + ) shouldBe mapOf("a" to listOf("b", "c")) + } + it("allows enabling dot notation") { decode("a.b=c") shouldBe mapOf("a.b" to "c") decode("a.b=c", DecodeOptions(allowDots = true)) shouldBe @@ -285,13 +322,13 @@ class DecodeSpec : decode("a[1]=b&a=c", DecodeOptions(listLimit = 20)) shouldBe mapOf("a" to listOf("b", "c")) decode("a[]=b&a=c", DecodeOptions(listLimit = 0)) shouldBe - mapOf("a" to mapOf("0" to "b", "c" to true)) + mapOf("a" to mapOf("0" to "b", "1" to "c")) decode("a[]=b&a=c") shouldBe mapOf("a" to listOf("b", "c")) decode("a=b&a[1]=c", DecodeOptions(listLimit = 20)) shouldBe mapOf("a" to listOf("b", "c")) decode("a=b&a[]=c", DecodeOptions(listLimit = 0)) shouldBe - mapOf("a" to listOf("b", mapOf("0" to "c"))) + mapOf("a" to mapOf("0" to "b", "1" to "c")) decode("a=b&a[]=c") shouldBe mapOf("a" to listOf("b", "c")) } @@ -907,6 +944,13 @@ class DecodeSpec : mapOf("foo" to listOf("bar", "baz")) } + it("duplicates: combine with listLimit < 0 preserves list") { + decode( + "foo=bar&foo=baz", + DecodeOptions(duplicates = Duplicates.COMBINE, listLimit = -1), + ) shouldBe mapOf("foo" to listOf("bar", "baz")) + } + it("duplicates: first") { decode("foo=bar&foo=baz", DecodeOptions(duplicates = Duplicates.FIRST)) shouldBe mapOf("foo" to "bar") @@ -997,6 +1041,17 @@ class DecodeSpec : } } + it("uses singular wording when parameterLimit is 1 and exceeded") { + val error = + shouldThrow { + decode( + "a=1&b=2", + DecodeOptions(parameterLimit = 1, throwOnLimitExceeded = true), + ) + } + error.message shouldBe "Parameter limit exceeded. Only 1 parameter allowed." + } + it("silently truncates when throwOnLimitExceeded is not given") { decode("a=1&b=2&c=3&d=4&e=5", DecodeOptions(parameterLimit = 3)) shouldBe mapOf("a" to "1", "b" to "2", "c" to "3") @@ -1009,6 +1064,19 @@ class DecodeSpec : ) shouldBe mapOf("a" to "1", "b" to "2", "c" to "3") } + it("ignores empty segments when applying parameter limit") { + decode("&&a=b", DecodeOptions(parameterLimit = 1)) shouldBe mapOf("a" to "b") + } + + it("does not count empty segments toward parameter limit when throwOnLimitExceeded") { + shouldNotThrow { + decode( + "&&a=b", + DecodeOptions(parameterLimit = 1, throwOnLimitExceeded = true), + ) shouldBe mapOf("a" to "b") + } + } + it("allows unlimited parameters when parameterLimit set to Infinity") { decode( "a=1&b=2&c=3&d=4&e=5&f=6", @@ -1016,6 +1084,18 @@ class DecodeSpec : ) shouldBe mapOf("a" to "1", "b" to "2", "c" to "3", "d" to "4", "e" to "5", "f" to "6") } + + it("filters empty segments with unlimited parameterLimit") { + decode("&&a=1&&b=2&&", DecodeOptions(parameterLimit = Int.MAX_VALUE)) shouldBe + mapOf("a" to "1", "b" to "2") + } + + it("supports throwOnLimitExceeded when parameterLimit is unlimited") { + decode( + "&&a=1&&b=2&&", + DecodeOptions(parameterLimit = Int.MAX_VALUE, throwOnLimitExceeded = true), + ) shouldBe mapOf("a" to "1", "b" to "2") + } } describe("list limit tests") { @@ -1059,12 +1139,10 @@ class DecodeSpec : } it("handles negative list limit correctly") { - shouldThrow { - decode( - "a[]=1&a[]=2", - DecodeOptions(listLimit = -1, throwOnLimitExceeded = true), - ) - } + decode( + "a[]=1&a[]=2", + DecodeOptions(listLimit = -1, throwOnLimitExceeded = true), + ) shouldBe mapOf("a" to listOf("1", "2")) } it("applies list limit to nested lists") { diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/EncodeSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/EncodeSpec.kt index 761d11a4..89bd4802 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/EncodeSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/EncodeSpec.kt @@ -16,6 +16,7 @@ import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.string.shouldMatch +import java.nio.ByteBuffer import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.time.Instant @@ -1198,6 +1199,44 @@ class EncodeSpec : calls shouldBe 5 } + it("applies dateSerializer when filter = FunctionFilter") { + val dt = LocalDateTime.of(2020, 1, 2, 3, 4, 5) + val filter = FunctionFilter { _, value -> value } + val serializeDate: DateSerializer = { d -> + d.toEpochSecond(ZoneOffset.UTC).toString() + } + val out = + encode( + mapOf("a" to dt), + EncodeOptions( + filter = filter, + dateSerializer = serializeDate, + encode = false, + ), + ) + out shouldBe "a=${serializeDate(dt)}" + } + + it("applies dateSerializer to COMMA lists when filter = FunctionFilter") { + val dt = LocalDateTime.of(2020, 1, 2, 3, 4, 5) + val filter = FunctionFilter { _, value -> value } + val serializeDate: DateSerializer = { d -> + d.toEpochSecond(ZoneOffset.UTC).toString() + } + val serialized = serializeDate(dt) + val out = + encode( + mapOf("d" to listOf(dt, dt)), + EncodeOptions( + filter = filter, + dateSerializer = serializeDate, + listFormat = ListFormat.COMMA, + encode = false, + ), + ) + out shouldBe "d=${serialized},${serialized}" + } + it("can disable uri encoding") { encode(mapOf("a" to "b"), EncodeOptions(encode = false)) shouldBe "a=b" @@ -1210,6 +1249,14 @@ class EncodeSpec : ) shouldBe "a=b&c" } + it("encode=false stringifies byte arrays and buffers") { + encode(mapOf("a" to "hi".toByteArray()), EncodeOptions(encode = false)) shouldBe + "a=hi" + + val buf = ByteBuffer.wrap("hi".toByteArray()) + encode(mapOf("a" to buf), EncodeOptions(encode = false)) shouldBe "a=hi" + } + it("can sort the keys") { val sort: Sorter = { a, b -> a.toString().compareTo(b.toString()) } @@ -1795,6 +1842,15 @@ class EncodeSpec : encode(mapOf("a" to listOf(a, b)), opts) shouldBe "a=${a},${b}" } + it("COMMA list stringifies byte arrays and buffers (encode=false)") { + val buf = ByteBuffer.wrap("hi".toByteArray()) + val bytes = "yo".toByteArray() + + val opts = EncodeOptions(encode = false, listFormat = ListFormat.COMMA) + + encode(mapOf("a" to listOf(buf, bytes)), opts) shouldBe "a=hi,yo" + } + it("COMMA list encodes comma when encode=true") { val a = Instant.parse("2020-01-02T03:04:05Z") val b = Instant.parse("2021-02-03T04:05:06Z") diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt index 7430c603..500e49b1 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt @@ -146,11 +146,11 @@ class QsParserSpec : decode("a[1]=b&a=c", options20) shouldBe mapOf("a" to listOf("b", "c")) - decode("a[]=b&a=c", options0) shouldBe mapOf("a" to mapOf("0" to "b", "c" to true)) + decode("a[]=b&a=c", options0) shouldBe mapOf("a" to mapOf("0" to "b", "1" to "c")) decode("a=b&a[1]=c", options20) shouldBe mapOf("a" to listOf("b", "c")) - decode("a=b&a[]=c", options0) shouldBe mapOf("a" to listOf("b", mapOf("0" to "c"))) + decode("a=b&a[]=c", options0) shouldBe mapOf("a" to mapOf("0" to "b", "1" to "c")) } it("should parse a nested array") { diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/UtilsSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/UtilsSpec.kt index 4ccad4f1..352a2ca7 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/UtilsSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/UtilsSpec.kt @@ -153,10 +153,39 @@ class UtilsSpec : Utils.encode("๐Ÿ˜€") shouldBe "%F0%9F%98%80" } + test("encodes lone surrogates as three-byte sequences") { + Utils.encode("\uD83D") shouldBe "%ED%A0%BD" + Utils.encode("\uDC00") shouldBe "%ED%B0%80" + } + + test("does not split surrogate pairs across segment boundaries") { + val input = "a".repeat(1023) + "๐Ÿ˜€" + val expected = "a".repeat(1023) + "%F0%9F%98%80" + Utils.encode(input) shouldBe expected + } + test("encodes ByteArray and ByteBuffer") { Utils.encode("รค".toByteArray(StandardCharsets.UTF_8)) shouldBe "%C3%A4" Utils.encode(ByteBuffer.wrap("hi".toByteArray())) shouldBe "hi" } + + test("encodes latin1 bytes through non-UTF decoder path") { + Utils.encode( + byteArrayOf(0xE4.toByte()), + charset = StandardCharsets.ISO_8859_1, + format = Format.RFC3986, + ) shouldBe "%E4" + } + + test("encodes direct and read-only ByteBuffers") { + val direct = ByteBuffer.allocateDirect(2) + direct.put("hi".toByteArray()) + direct.flip() + Utils.encode(direct) shouldBe "hi" + + val readOnly = ByteBuffer.wrap("hi".toByteArray()).asReadOnlyBuffer() + Utils.encode(readOnly) shouldBe "hi" + } } context("Utils.decode") { @@ -468,6 +497,160 @@ class UtilsSpec : ) shouldBe mapOf("foo" to listOf(mapOf("baz" to listOf("15", "16")))) } + test("replaces undefined holes when merging list-of-maps") { + val result = + Utils.merge( + listOf(Undefined(), mapOf("a" to "b")), + listOf(mapOf("x" to "y"), Undefined()), + ) + result shouldBe listOf(mapOf("x" to "y"), mapOf("a" to "b")) + } + + test("normalizes list merge with undefined holes when parseLists disabled") { + val result = + Utils.merge( + listOf(Undefined(), mapOf("a" to "b")), + listOf(Undefined(), Undefined()), + DecodeOptions(parseLists = false), + ) + result shouldBe mapOf("1" to mapOf("a" to "b")) + } + + test("merge appends scalar to overflow map") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + + val result = Utils.merge(overflow, "d") + result.shouldBeInstanceOf() + @Suppress("UNCHECKED_CAST") val map = result as Map + map["3"] shouldBe "d" + } + + test("merge appends iterable to overflow map") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + + val result = Utils.merge(overflow, listOf("d", Undefined(), "e")) + val map = result.shouldBeInstanceOf() + map["3"] shouldBe "d" + map["4"] shouldBe "e" + map.maxIndex shouldBe 4 + } + + test("merge appending only Undefined iterable to overflow map keeps maxIndex") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + + val result = Utils.merge(overflow, listOf(Undefined(), Undefined())) + val map = result.shouldBeInstanceOf() + map["0"] shouldBe "a" + map["1"] shouldBe "b" + map["2"] shouldBe "c" + map.maxIndex shouldBe 2 + } + + test("merge scalar with overflow map shifts indices") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + + val result = Utils.merge("root", overflow) + val map = result.shouldBeInstanceOf() + map["0"] shouldBe "root" + map["1"] shouldBe "a" + map["2"] shouldBe "b" + map["3"] shouldBe "c" + map.maxIndex shouldBe 3 + } + + test("merge null with overflow map preserves indices") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + + val result = Utils.merge(null, overflow) + val map = result.shouldBeInstanceOf() + map["0"] shouldBe "a" + map["1"] shouldBe "b" + map["2"] shouldBe "c" + map.maxIndex shouldBe 2 + } + + test("merge null with mixed-key overflow map preserves numeric and string keys") { + val overflow = + Utils.OverflowMap().apply { + this["0"] = "a" + this["meta"] = "m" + maxIndex = 0 + } + + val result = Utils.merge(null, overflow) + val map = result.shouldBeInstanceOf() + map["0"] shouldBe "a" + map["meta"] shouldBe "m" + map.maxIndex shouldBe 0 + } + + test("merge map into overflow map preserves maxIndex") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + + val result = Utils.merge(overflow, mapOf("extra" to "x")) + val map = result.shouldBeInstanceOf() + map["extra"] shouldBe "x" + map.maxIndex shouldBe 2 + } + + test("merge scalar with mixed-key overflow map shifts numeric keys only") { + val overflow = + Utils.OverflowMap().apply { + this["0"] = "a" + this["meta"] = "m" + maxIndex = 0 + } + + val result = Utils.merge("root", overflow) + val map = result.shouldBeInstanceOf() + map["0"] shouldBe "root" + map["1"] shouldBe "a" + map["meta"] shouldBe "m" + map.maxIndex shouldBe 1 + } + + test("merge overflow map updates maxIndex for supported key types only") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + val marker = Any() + + val merged = + Utils.merge( + overflow, + linkedMapOf( + -1 to "neg", + 5 to "int", + 7L to "long", + Long.MAX_VALUE to "too-big", + "9" to "string-int", + "meta" to "meta", + marker to "marker", + ), + ) + + val map = merged.shouldBeInstanceOf() + map["9"] shouldBe "string-int" + map["meta"] shouldBe "meta" + map.maxIndex shouldBe 9 + } + + test("merge list-of-maps normalizes undefined tail when parseLists is false") { + val result = + Utils.merge( + listOf(mapOf("a" to "1")), + listOf(mapOf("b" to "2"), Undefined()), + DecodeOptions(parseLists = false), + ) + + result shouldBe mapOf("0" to mapOf("a" to "1", "b" to "2")) + } + test("merges two objects with the same key and different values into a list") { Utils.merge( mapOf("foo" to listOf(mapOf("a" to "b"))), @@ -477,6 +660,11 @@ class UtilsSpec : test("merges true into null") { Utils.merge(null, true) shouldBe listOf(null, true) } + test("returns target unchanged when source is null") { + val target = mapOf("a" to 1) + Utils.merge(target, null) shouldBe target + } + test("merges null into a list") { val result = Utils.merge(null, listOf(42)) result shouldBe listOf(null, 42) @@ -495,6 +683,14 @@ class UtilsSpec : result.shouldBeInstanceOf>() } + test("ignores Undefined source when target is map") { + Utils.merge(mapOf("a" to "b"), Undefined()) shouldBe mapOf("a" to "b") + } + + test("does not add empty-string key when merging scalar into map") { + Utils.merge(mapOf("a" to "b"), "") shouldBe mapOf("a" to "b") + } + test("merges two objects with the same key") { val result = Utils.merge(mapOf("a" to "b"), mapOf("a" to "c")) result shouldBe mapOf("a" to listOf("b", "c")) @@ -701,6 +897,34 @@ class UtilsSpec : } context("Utils.combine") { + test("combine appends iterable into overflow map") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + + val result = Utils.combine(overflow, listOf("d", "e"), limit = 2) + val map = result.shouldBeInstanceOf() + map["3"] shouldBe "d" + map["4"] shouldBe "e" + map.maxIndex shouldBe 4 + } + + test("combine skips Undefined when appending iterable into overflow map") { + val overflow = Utils.combine(listOf("a", "b"), "c", limit = 2) + overflow.shouldBeInstanceOf() + + val result = Utils.combine(overflow, listOf("d", Undefined(), "e"), limit = 2) + val map = result.shouldBeInstanceOf() + map["3"] shouldBe "d" + map["4"] shouldBe "e" + map.maxIndex shouldBe 4 + } + + test("combine does not overflow when listLimit is negative") { + val result = Utils.combine("a", "b", limit = -1) + result.shouldBeInstanceOf>() + result shouldBe listOf("a", "b") + } + test("both lists") { val a = listOf(1) val b = listOf(2) diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/internal/EncoderInternalSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/internal/EncoderInternalSpec.kt index 19707e4b..2461fb12 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/internal/EncoderInternalSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/internal/EncoderInternalSpec.kt @@ -3,6 +3,7 @@ package io.github.techouse.qskotlin.unit.internal import io.github.techouse.qskotlin.enums.Format import io.github.techouse.qskotlin.enums.ListFormat import io.github.techouse.qskotlin.internal.Encoder +import io.github.techouse.qskotlin.models.FunctionFilter import io.github.techouse.qskotlin.models.IterableFilter import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec @@ -152,6 +153,45 @@ class EncoderInternalSpec : result shouldBe "items[]" } + it( + "returns empty suffix for empty non-collection iterable when allowEmptyLists enabled" + ) { + val result = + Encoder.encode( + data = + object : Iterable { + override fun iterator(): Iterator = + emptyList().iterator() + }, + undefined = false, + sideChannel = mutableMapOf(), + prefix = "items", + allowEmptyLists = true, + ) + + result shouldBe "items[]" + } + + it("uses iterableList size for commaRoundTrip with non-collection iterables") { + val result = + Encoder.encode( + data = + object : Iterable { + override fun iterator(): Iterator = + listOf("solo").iterator() + }, + undefined = false, + sideChannel = mutableMapOf(), + prefix = "items", + generateArrayPrefix = ListFormat.INDICES.generator, + commaRoundTrip = true, + encoder = { value, _, _ -> value?.toString() ?: "" }, + formatter = { v -> v }, + ) + + result shouldBe listOf("items[][0]=solo") + } + it("stringifies temporal comma lists when no serializer supplied") { val instant = Instant.parse("2020-01-01T00:00:00Z") val date = LocalDateTime.parse("2020-01-01T00:00:00") @@ -209,6 +249,26 @@ class EncoderInternalSpec : } .message shouldBe "Cyclic object value" } + + it("detects cycles introduced by filter") { + val root = mutableMapOf() + root["a"] = mutableMapOf("b" to "c") + + val filter = FunctionFilter { prefix, value -> + if (prefix.contains("a")) root else value + } + + shouldThrow { + Encoder.encode( + data = root, + undefined = false, + sideChannel = mutableMapOf(), + prefix = "root", + filter = filter, + ) + } + .message shouldBe "Cyclic object value" + } } }) diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt index be1577d6..6782d396 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/EncodeOptionsSpec.kt @@ -145,6 +145,7 @@ class EncodeOptionsSpec : .skipNulls(true) .strictNullHandling(true) .commaRoundTrip(true) + .commaCompactNulls(true) .sort { a, b -> (a.toString()).compareTo(b.toString()) } .build() @@ -164,6 +165,7 @@ class EncodeOptionsSpec : options.skipNulls shouldBe true options.strictNullHandling shouldBe true options.commaRoundTrip shouldBe true + options.commaCompactNulls shouldBe true options.getAllowDots shouldBe true options.getListFormat shouldBe ListFormat.BRACKETS options.sort!!("b", "a") shouldBe "b".compareTo("a")