Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0b4dbc2
:sparkles: add DecodeKind enum to distinguish decoding context for ke…
techouse Aug 20, 2025
7a01ca7
:bug: protect encoded dots in key decoding to prevent premature conve…
techouse Aug 20, 2025
c5997eb
:bug: handle lowercase '%2e' in key decoding and improve bracketed ke…
techouse Aug 20, 2025
c71c9e8
:white_check_mark: add comprehensive tests for encoded dot handling i…
techouse Aug 20, 2025
0023f00
:wastebasket: deprecate getDecoder in favor of context-aware decode m…
techouse Aug 20, 2025
2f51003
:bulb: update Decoder interface documentation to use code formatting …
techouse Aug 20, 2025
d2514e9
:children_crossing: add LegacyDecoder typealias and deprecate legacy …
techouse Aug 21, 2025
800d545
:bulb: update deprecation annotation for indices option in EncodeOpti…
techouse Aug 21, 2025
d0512c6
:bug: fix key segment handling for depth 0 to preserve original key w…
techouse Aug 21, 2025
4276d9d
:bug: optimize protectEncodedDotsForKeys to skip processing when no e…
techouse Aug 21, 2025
f8eaf26
:bug: replace regex-based dot-to-bracket conversion with top-level pa…
techouse Aug 21, 2025
f237e78
:white_check_mark: add tests for key coercion and depth=0 behavior wi…
techouse Aug 21, 2025
ff5f1d7
:white_check_mark: update decoder tests to handle DecodeKind for sele…
techouse Aug 21, 2025
d77f9c0
:art: remove explicit Decoder type annotations in custom decoder test…
techouse Aug 21, 2025
5cae1b3
:white_check_mark: add tests for defaultDecode to verify encoded dot …
techouse Aug 21, 2025
55f2081
:bulb: clarify deprecation message for legacy decoder adapter and doc…
techouse Aug 21, 2025
b970059
:art: reformat deprecation and documentation comments for improved re…
techouse Aug 21, 2025
5d394a7
:bug: fix allowDots logic to ensure decodeDotInKeys requires allowDot…
techouse Aug 21, 2025
3406b3b
:art: rename local variable for custom decoder in encoding test for c…
techouse Aug 21, 2025
465ff22
:white_check_mark: add tests for dot-to-bracket conversion guardrails…
techouse Aug 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.github.techouse.qskotlin.enums

import io.github.techouse.qskotlin.enums.DecodeKind.KEY
import io.github.techouse.qskotlin.enums.DecodeKind.VALUE

/**
* Decoding context for a scalar token.
* - [KEY]: the token is a key or key segment. Callers may want to preserve percent-encoded dots
* (%2E / %2e) until after key-splitting.
* - [VALUE]: the token is a value; typically fully percent-decode.
*/
enum class DecodeKind {
KEY,
VALUE,
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,12 @@ internal object Decoder {
var value: Any?

if (pos == -1) {
key = options.getDecoder(part, charset).toString()
// Decode a bare key (no '=') using key-aware decoding
key = options.decodeKey(part, charset).orEmpty()
value = if (options.strictNullHandling) null else ""
} else {
key = options.getDecoder(part.take(pos), charset).toString()
// Decode the key slice as a key; values decode as values
key = options.decodeKey(part.take(pos), charset).orEmpty()
value =
Utils.apply<Any?>(
parseListValue(
Expand All @@ -124,7 +126,7 @@ internal object Decoder {
} else 0,
)
) { v: Any? ->
options.getDecoder((v as String?), charset)
options.decodeValue(v as String?, charset)
}
}

Expand Down Expand Up @@ -202,12 +204,15 @@ internal object Decoder {
val mutableObj = LinkedHashMap<String, Any?>(1)

val cleanRoot =
if (root.startsWith("[") && root.endsWith("]")) {
root.substring(1, root.length - 1)
if (root.startsWith("[")) {
val last = root.lastIndexOf(']')
if (last > 0) root.substring(1, last) else root.substring(1)
} else root

val decodedRoot =
if (options.getDecodeDotInKeys) cleanRoot.replace("%2E", ".") else cleanRoot
if (options.getDecodeDotInKeys)
cleanRoot.replace("%2E", ".").replace("%2e", ".")
else cleanRoot

val isPureNumeric = decodedRoot.isNotEmpty() && decodedRoot.all { it.isDigit() }
val idx: Int? = if (isPureNumeric) decodedRoot.toInt() else null
Expand All @@ -232,8 +237,7 @@ internal object Decoder {

// Otherwise, treat it as a map with *string* key (even if numeric)
else -> {
val keyForMap = decodedRoot
mutableObj[keyForMap] = leaf
mutableObj[decodedRoot] = leaf
obj = mutableObj
}
}
Expand Down Expand Up @@ -274,10 +278,53 @@ internal object Decoder {
}

/**
* Regular expression to match dots followed by non-dot and non-bracket characters. This is used
* to replace dots in keys with brackets for parsing.
* Converts a dot notation key to bracket notation at the top level.
*
* @param s The string to convert, which may contain dot notation.
* @return The converted string with brackets replacing dots at the top level.
*/
private val DOT_TO_BRACKET = Regex("""\.([^.\[]+)""")
private fun dotToBracketTopLevel(s: String): String {
val sb = StringBuilder(s.length)
var depth = 0
var i = 0
while (i < s.length) {
val ch = s[i]
when (ch) {
'[' -> {
depth++
sb.append(ch)
i++
}
']' -> {
if (depth > 0) depth--
sb.append(ch)
i++
}
'.' -> {
if (depth == 0) {
// collect the next segment name (stop at '.' or '[')
val start = ++i
var j = start
while (j < s.length && s[j] != '.' && s[j] != '[') j++
if (j > start) {
sb.append('[').append(s, start, j).append(']')
i = j
} else {
sb.append('.') // nothing to convert
}
} else {
sb.append('.')
i++
}
}
else -> {
sb.append(ch)
i++
}
}
}
return sb.toString()
}

/**
* Splits a key into segments based on brackets and dots, handling depth and strictness.
Expand All @@ -295,17 +342,15 @@ internal object Decoder {
maxDepth: Int,
strictDepth: Boolean,
): List<String> {
// Apply dot→bracket *before* splitting, but when depth == 0, we do NOT split at all and do
// NOT throw.
val key: String =
if (allowDots) originalKey.replace(DOT_TO_BRACKET) { "[${it.groupValues[1]}]" }
else originalKey

// Depth 0 semantics: use the original key as a single segment; never throw.
if (maxDepth <= 0) {
return listOf(key)
return listOf(originalKey)
}

// Apply dot→bracket *before* splitting, but when depth == 0, we do NOT split at all and do
// NOT throw.
val key: String = if (allowDots) dotToBracketTopLevel(originalKey) else originalKey

val segments = ArrayList<String>(key.count { it == '[' } + 1)

val first = key.indexOf('[')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
package io.github.techouse.qskotlin.models

import io.github.techouse.qskotlin.enums.DecodeKind
import io.github.techouse.qskotlin.enums.Duplicates
import io.github.techouse.qskotlin.internal.Utils
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets

/**
* A function that decodes a value from a query string or form data. It takes a value and an
* optional charset, returning the decoded value.
*
* @param value The encoded value to decode.
* @param charset The character set to use for decoding, if any.
* @return The decoded value, or null if the value is not present.
*/
typealias Decoder = (value: String?, charset: Charset?) -> Any?
/** Unified scalar decoder. Implementations may ignore `charset` and/or `kind`. */
fun interface Decoder {
fun decode(value: String?, charset: Charset?, kind: DecodeKind?): Any?
}

/** Back‑compat adapter for `(value, charset) -> Any?` decoders. */
@Deprecated(
message =
"Use Decoder fun interface; wrap your two‑arg lambda: Decoder { v, c, _ -> legacy(v, c) }",
replaceWith = ReplaceWith("Decoder { value, charset, _ -> legacyDecoder(value, charset) }"),
level = DeprecationLevel.WARNING,
)
typealias LegacyDecoder = (String?, Charset?) -> Any?

/** Options that configure the output of Qs.decode. */
data class DecodeOptions(
Expand All @@ -22,6 +27,13 @@ data class DecodeOptions(

/** Set a Decoder to affect the decoding of the input. */
private val decoder: Decoder? = null,
@Deprecated(
message = "Use `decoder` fun interface; this will be removed in a future major release",
replaceWith = ReplaceWith("decoder"),
level = DeprecationLevel.WARNING,
)
@Suppress("DEPRECATION")
private val legacyDecoder: LegacyDecoder? = null,

/**
* Set to `true` to decode dots in keys.
Expand Down Expand Up @@ -107,8 +119,11 @@ data class DecodeOptions(
val parseLists: Boolean = true,

/**
* Set to `true` to add a layer of protection by throwing an error when the limit is exceeded,
* allowing you to catch and handle such cases.
* Enforce the [depth] limit when parsing nested keys.
*
* When `true`, exceeding [depth] throws an `IndexOutOfBoundsException` during key splitting.
* When `false` (default), any remainder beyond [depth] is treated as a single trailing segment
* (matching the reference `qs` behavior).
*/
val strictDepth: Boolean = false,

Expand All @@ -118,11 +133,21 @@ data class DecodeOptions(
/** Set to `true` to throw an error when the limit is exceeded. */
val throwOnLimitExceeded: Boolean = false,
) {
/** The List encoding format to use. */
/**
* Effective `allowDots` value.
*
* Returns `true` when `allowDots == true` **or** when `decodeDotInKeys == true` (since decoding
* dots in keys implies dot‑splitting). Otherwise returns `false`.
*/
val getAllowDots: Boolean
get() = allowDots ?: (decodeDotInKeys == true)

/** The List encoding format to use. */
/**
* Effective `decodeDotInKeys` value.
*
* Defaults to `false` when unspecified. When `true`, encoded dots (`%2E`/`%2e`) inside key
* segments are mapped to `.` **after** splitting, without introducing extra dot‑splits.
*/
val getDecodeDotInKeys: Boolean
get() = decodeDotInKeys ?: false

Expand All @@ -131,13 +156,126 @@ data class DecodeOptions(
"Invalid charset"
}
require(parameterLimit > 0) { "Parameter limit must be positive" }
require(!getDecodeDotInKeys || getAllowDots) {
// If decodeDotInKeys is enabled, allowDots must not be explicitly false.
require(!getDecodeDotInKeys || allowDots != false) {
"decodeDotInKeys requires allowDots to be true"
}
}

/** Decode the input using the specified Decoder. */
/**
* Unified scalar decode with key/value context.
*
* Uses the provided [decoder] when set; otherwise falls back to [Utils.decode]. For backward
* compatibility, a [legacyDecoder] `(value, charset)` can be supplied and is adapted
* internally. The [kind] will be [DecodeKind.KEY] for keys (and key segments) and
* [DecodeKind.VALUE] for values.
*/
internal fun decode(
value: String?,
charset: Charset? = null,
kind: DecodeKind = DecodeKind.VALUE,
): Any? {
@Suppress("DEPRECATION")
val d = decoder ?: legacyDecoder?.let { legacy -> Decoder { v, c, _ -> legacy(v, c) } }
return if (d != null) {
d.decode(value, charset, kind) // honor nulls from user decoder
} else {
defaultDecode(value, charset, kind)
}
}

/**
* Default library decode.
*
* For [DecodeKind.KEY], protects encoded dots (`%2E`/`%2e`) **before** percent‑decoding so key
* splitting and post‑split mapping run on the intended tokens.
*/
private fun defaultDecode(value: String?, charset: Charset?, kind: DecodeKind): Any? {
if (value == null) return null
if (kind == DecodeKind.KEY) {
val protected =
protectEncodedDotsForKeys(value, includeOutsideBrackets = (allowDots == true))
return Utils.decode(protected, charset)
}
return Utils.decode(value, charset)
}

/**
* Double‑encode %2E/%2e in KEY strings so the percent‑decoder does not turn them into '.' too
* early.
*
* When [includeOutsideBrackets] is true, occurrences both inside and outside bracket segments
* are protected. Otherwise, only those **inside** `[...]` are protected. Note: only literal
* `[`/`]` affect depth; percent‑encoded brackets (`%5B`/`%5D`) are treated as content, not
* structure.
*/
private fun protectEncodedDotsForKeys(input: String, includeOutsideBrackets: Boolean): String {
val pct = input.indexOf('%')
if (pct < 0) return input
if (input.indexOf("2E", pct) < 0 && input.indexOf("2e", pct) < 0) return input
val n = input.length
val sb = StringBuilder(n + 8)
var depth = 0
var i = 0
while (i < n) {
when (val ch = input[i]) {
'[' -> {
depth++
sb.append(ch)
i++
}
']' -> {
if (depth > 0) depth--
sb.append(ch)
i++
}
'%' -> {
if (
i + 2 < n &&
input[i + 1] == '2' &&
(input[i + 2] == 'E' || input[i + 2] == 'e')
) {
val inside = depth > 0
if (inside || includeOutsideBrackets) {
sb.append("%25").append(if (input[i + 2] == 'E') "2E" else "2e")
} else {
sb.append('%').append('2').append(input[i + 2])
}
i += 3
} else {
sb.append(ch)
i++
}
}
else -> {
sb.append(ch)
i++
}
}
}
return sb.toString()
}

/**
* Back‑compat helper: decode a value without key/value kind context.
*
* Prefer calling [decode] directly (or [decodeKey]/[decodeValue] for explicit context).
*/
@Deprecated(
message =
"Deprecated: use decodeKey/decodeValue (or decode(value, charset, kind)) to honor key/value context. This will be removed in the next major.",
replaceWith = ReplaceWith("decode(value, charset)"),
level = DeprecationLevel.WARNING,
)
@Suppress("unused")
@JvmOverloads
fun getDecoder(value: String?, charset: Charset? = null): Any? =
if (decoder != null) decoder.invoke(value, charset) else Utils.decode(value, charset)
fun getDecoder(value: String?, charset: Charset? = null): Any? = decode(value, charset)

/** Convenience: decode a key to String? */
internal fun decodeKey(value: String?, charset: Charset?): String? =
decode(value, charset, DecodeKind.KEY)?.toString() // keys are always coerced to String

/** Convenience: decode a value */
internal fun decodeValue(value: String?, charset: Charset?): Any? =
decode(value, charset, DecodeKind.VALUE)
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ data class EncodeOptions(

/** The List encoding format to use. */
private val listFormat: ListFormat? = null,
@Deprecated("Use listFormat instead") val indices: Boolean? = null,
@Deprecated(
message = "Use listFormat instead",
replaceWith = ReplaceWith("listFormat"),
level = DeprecationLevel.WARNING,
)
val indices: Boolean? = null,

/** Set to `true` to use dot Map notation in the encoded output. */
private val allowDots: Boolean? = null,
Expand Down
Loading
Loading