Skip to content

A query string encoding and decoding library for Android and Kotlin/JVM. Ported from qs for JavaScript.

License

Notifications You must be signed in to change notification settings

techouse/qs-kotlin

qs-kotlin

qs-kotlin

A query string encoding and decoding library for Android and Kotlin/JVM.

Ported from qs for JavaScript.

Kotlin 2.2.21 JVM 17 Maven Central Version Android AAR Test codecov Codacy Badge GitHub GitHub Sponsors GitHub Repo stars

This repo provides:

  • qs-kotlin – the core JVM library (Jar)
  • qs-kotlin-android – a thin Android AAR wrapper that re-exports the same API

If you only target the JVM (including Android projects that are fine with a plain Jar), just use qs-kotlin. The Android wrapper is provided for teams that prefer an AAR coordinate and AGP metadata.


Highlights

  • Nested maps and lists: foo[bar][baz]=qux{ foo: { bar: { baz: "qux" } } }
  • Multiple list formats (indices, brackets, repeat, comma)
  • Dot-notation support (a.b=c) and "."-encoding toggles
  • UTF-8 and ISO-8859-1 charsets, plus optional charset sentinel (utf8=✓)
  • Custom encoders/decoders, key sorting, filtering, and strict null handling
  • Supports LocalDateTime/Instant serialization via a pluggable serializer
  • Extensive tests (Kotest), performance-minded implementation

Installation

JVM (Jar)

Kotlin:

dependencies {
    implementation("io.github.techouse:qs-kotlin:<version>")
}

Java (Gradle Groovy DSL):

dependencies {
    implementation 'io.github.techouse:qs-kotlin:<version>'
}

Android (AAR wrapper)

Kotlin:

dependencies {
    implementation("io.github.techouse:qs-kotlin-android:<version>")
}

Java (Gradle Groovy DSL):

dependencies {
    implementation 'io.github.techouse:qs-kotlin-android:<version>'
}

The Android AAR depends on Java 17 APIs. If your app’s minSdk < 26 and you use java.time transitively, enable core library desugaring in your app:

Kotlin:

android {
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
        isCoreLibraryDesugaringEnabled = true
    }
}
dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}

Java (Gradle Groovy DSL):

android {
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_17
    targetCompatibility JavaVersion.VERSION_17
    coreLibraryDesugaringEnabled = true
  }
}
dependencies {
  coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}

Requirements

  • Kotlin 2.2.20+
  • Java 17+
  • Android wrapper: AGP 8.7+, compileSdk 35, minSdk 25

Quick start

Kotlin:

import io.github.techouse.qskotlin.QS

// Decode
val obj: Map<String, Any?> = QS.decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b")
// -> mapOf("foo" to mapOf("bar" to "baz", "list" to listOf("a", "b")))

// Encode
val qs: String = QS.encode(mapOf("foo" to mapOf("bar" to "baz")))
// -> "foo%5Bbar%5D=baz"

Java:

import io.github.techouse.qskotlin.QS;

// Decode
Map<@NotNull String, @Nullable Object> obj = QS.decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b");
// -> {foo={bar=baz, list=[a, b]}}

// Encode
String qs = QS.encode(Map.of("foo", Map.of("bar", "baz")));
// -> "foo%5Bbar%5D=baz"

Usage

Simple

Kotlin:

// Decode
val decoded: Map<String, Any?> = QS.decode("a=c")
// => mapOf("a" to "c")

// Encode
val encoded: String = QS.encode(mapOf("a" to "c"))
// => "a=c"

Java:

// Decode
Map<@NotNull String, @Nullable Object> decoded = QS.decode("a=c");
// => {a=c}

// Encode
String encoded = QS.encode(Map.of("a", "c"));
// => "a=c"

Decoding

Nested maps

Kotlin:

QS.decode("foo[bar]=baz")
// => mapOf("foo" to mapOf("bar" to "baz"))

QS.decode("a%5Bb%5D=c")
// => mapOf("a" to mapOf("b" to "c"))

QS.decode("foo[bar][baz]=foobarbaz")
// => mapOf("foo" to mapOf("bar" to mapOf("baz" to "foobarbaz")))

Java:

QS.decode("foo[bar]=baz");
// => {foo={bar=baz}}

QS.decode("a%5Bb%5D=c");
// => {a={b=c}}

QS.decode("foo[bar][baz]=foobarbaz");
// => {foo={bar={baz=foobarbaz}}}

Depth (default: 5)

Beyond the configured depth, remaining bracket content is kept as literal text:

Kotlin:

QS.decode("a[b][c][d][e][f][g][h][i]=j")
// => mapOf("a" to mapOf("b" to mapOf("c" to mapOf("d" to mapOf("e" to mapOf("f" to mapOf("[g][h][i]" to "j")))))))

Java:

QS.decode("a[b][c][d][e][f][g][h][i]=j");
// => {a={b={c={d={e={f={[g][h][i]=j}}}}}}}

Override depth:

Kotlin:

QS.decode(
  "a[b][c][d][e][f][g][h][i]=j",
  DecodeOptions(depth = 1)
)
// => mapOf("a" to mapOf("b" to mapOf("[c][d][e][f][g][h][i]" to "j")))

Java:

QS.decode(
  "a[b][c][d][e][f][g][h][i]=j",
  DecodeOptions.builder()
    .depth(1)
    .build()
);
// => {a={b={[c][d][e][f][g][h][i]=j}}}

Parameter limit

Kotlin:

QS.decode(
  "a=b&c=d",
  DecodeOptions(parameterLimit = 1)
)
// => mapOf("a" to "b")

Java:

QS.decode(
  "a=b&c=d",
  DecodeOptions.builder()
    .parameterLimit(1)
    .build()
);
// => {a=b}

Ignore leading ?

Kotlin:

QS.decode(
  "?a=b&c=d",
  DecodeOptions(ignoreQueryPrefix = true)
)
// => mapOf("a" to "b", "c" to "d")

Java:

QS.decode(
  "?a=b&c=d",
  DecodeOptions.builder()
    .ignoreQueryPrefix(true)
    .build()
);
// => {a=b, c=d}

Custom delimiter (string or regex)

Kotlin:

QS.decode(
  "a=b;c=d",
  DecodeOptions(delimiter = StringDelimiter(";"))
)
// => mapOf("a" to "b", "c" to "d")

QS.decode(
  "a=b;c=d",
  DecodeOptions(delimiter = RegexDelimiter("[;,]"))
)
// => mapOf("a" to "b", "c" to "d")

Java:

QS.decode(
  "a=b;c=d",
  DecodeOptions.builder()
    .delimiter(Delimiter.SEMICOLON)
    .build()
);
// => {a=b, c=d}

QS.decode(
  "a=b;c=d",
  DecodeOptions.builder()
    .delimiter(new RegexDelimiter("[;,]"))
    .build()
);
// => {a=b, c=d}

Dot-notation and “decode dots in keys”

Kotlin:

QS.decode(
  "a.b=c",
  DecodeOptions(allowDots = true)
)
// => mapOf("a" to mapOf("b" to "c"))

QS.decode(
  "name%252Eobj.first=John&name%252Eobj.last=Doe",
  DecodeOptions(decodeDotInKeys = true)
)
// => mapOf("name.obj" to mapOf("first" to "John", "last" to "Doe"))

Java:

QS.decode(
  "a.b=c",
  DecodeOptions.builder()
    .allowDots(true)
    .build()
);
// => {a={b=c}}

QS.decode(
  "name%252Eobj.first=John&name%252Eobj.last=Doe",
  DecodeOptions.builder()
    .decodeDotInKeys(true)
    .build()
);
// => {name.obj={first=John, last=Doe}}

Empty lists

Kotlin:

QS.decode(
  "foo[]&bar=baz",
  DecodeOptions(allowEmptyLists = true)
)
// => mapOf("foo" to emptyList<String>(), "bar" to "baz")

Java:

QS.decode(
  "foo[]&bar=baz",
  DecodeOptions.builder()
    .allowEmptyLists(true)
    .build()
);
// => {foo=[], bar=baz}

Duplicates

Kotlin:

QS.decode("foo=bar&foo=baz")
// => mapOf("foo" to listOf("bar", "baz"))

QS.decode(
  "foo=bar&foo=baz",
  DecodeOptions(duplicates = Duplicates.COMBINE)
)
// => same as above

QS.decode(
  "foo=bar&foo=baz",
  DecodeOptions(duplicates = Duplicates.FIRST)
)
// => mapOf("foo" to "bar")

QS.decode(
  "foo=bar&foo=baz",
  DecodeOptions(duplicates = Duplicates.LAST)
)
// => mapOf("foo" to "baz")

Java:

QS.decode("foo=bar&foo=baz");
// => {foo=[bar, baz]}

QS.decode(
  "foo=bar&foo=baz",
  DecodeOptions.builder()
    .duplicates(Duplicates.COMBINE)
    .build()
);
// => same as above

QS.decode(
  "foo=bar&foo=baz",
  DecodeOptions.builder()
    .duplicates(Duplicates.FIRST)
    .build()
);
// => {foo=bar}

QS.decode(
  "foo=bar&foo=baz",
  DecodeOptions.builder()
    .duplicates(Duplicates.LAST)
    .build()
);
// => {foo=baz}

Charset and sentinel

Kotlin:

// latin1
QS.decode(
  "a=%A7",
  DecodeOptions(charset = StandardCharsets.ISO_8859_1)
)
// => mapOf("a" to "§")

// Sentinels
QS.decode(
  "utf8=%E2%9C%93&a=%C3%B8",
  DecodeOptions(
    charset = StandardCharsets.ISO_8859_1,
    charsetSentinel = true
  )
)
// => mapOf("a" to "ø")

QS.decode(
  "utf8=%26%2310003%3B&a=%F8",
  DecodeOptions(
    charset = StandardCharsets.UTF_8,
    charsetSentinel = true
  )
)
// => mapOf("a" to "ø")

Java:

QS.decode(
  "a=%A7",
  DecodeOptions.builder()
    .charset(StandardCharsets.ISO_8859_1)
    .build()
);
// => {a=§}

QS.decode(
  "utf8=%E2%9C%93&a=%C3%B8",
  DecodeOptions.builder()
    .charset(StandardCharsets.ISO_8859_1)
    .charsetSentinel(true)
    .build()
);
// => {a=ø}

QS.decode(
  "utf8=%26%2310003%3B&a=%F8",
  DecodeOptions.builder()
    .charset(StandardCharsets.UTF_8)
    .charsetSentinel(true)
    .build()
);
// => {a=ø}

Interpret numeric entities (&#1234;)

Kotlin:

QS.decode(
  "a=%26%239786%3B",
  DecodeOptions(
    charset = StandardCharsets.ISO_8859_1,
    interpretNumericEntities = true
  )
)
// => mapOf("a" to "☺")

Java:

QS.decode(
  "a=%26%239786%3B",
  DecodeOptions.builder()
    .charset(StandardCharsets.ISO_8859_1)
    .interpretNumericEntities(true)
    .build()
);
// => {a=☺}

Lists

Kotlin:

QS.decode("a[]=b&a[]=c")
// => mapOf("a" to listOf("b", "c"))

QS.decode("a[1]=c&a[0]=b")
// => mapOf("a" to listOf("b", "c"))

QS.decode("a[1]=b&a[15]=c")
// => mapOf("a" to listOf("b", "c"))

QS.decode("a[]=&a[]=b")
// => mapOf("a" to listOf("", "b"))

Java:

QS.decode("a[]=b&a[]=c");
// => {a=[b, c]}

QS.decode("a[1]=c&a[0]=b");
// => {a=[b, c]}

QS.decode("a[1]=b&a[15]=c");
// => {a=[b, c]}

QS.decode("a[]=&a[]=b");
// => {a=["", b]}

Large indices convert to a map by default:

Kotlin:

QS.decode("a[100]=b")
// => mapOf("a" to mapOf(100 to "b"))

Java:

QS.decode("a[100]=b");
// => {a={100=b}}

Disable list parsing:

Kotlin:

QS.decode(
  "a[]=b",
  DecodeOptions(parseLists = false)
)
// => mapOf("a" to mapOf(0 to "b"))

Java:

QS.decode(
  "a[]=b",
  DecodeOptions.builder()
    .parseLists(false)
    .build()
);
// => {a={0=b}}

Mixing notations merges into a map:

Kotlin:

QS.decode("a[0]=b&a[b]=c")
// => mapOf("a" to mapOf(0 to "b", "b" to "c"))

Java:

QS.decode("a[0]=b&a[b]=c");
// => {a={0=b, b=c}}

Comma-separated values:

Kotlin:

QS.decode(
  "a=b,c",
  DecodeOptions(comma = true)
)
// => mapOf("a" to listOf("b", "c"))

Java:

QS.decode(
  "a=b,c",
  DecodeOptions.builder()
    .comma(true)
    .build()
);
// => {a=[b, c]}

Primitive/scalar values

All values decode as strings by default:

Kotlin:

QS.decode("a=15&b=true&c=null")
// => mapOf("a" to "15", "b" to "true", "c" to "null")

Java:

QS.decode("a=15&b=true&c=null");
// => {a=15, b=true, c=null}

Encoding

Basics

Kotlin:

QS.encode(mapOf("a" to "b"))
// => "a=b"

QS.encode(mapOf("a" to mapOf("b" to "c")))
// => "a%5Bb%5D=c"

Java:

QS.encode(Map.of("a", "b"));
// => "a=b"

QS.encode(Map.of("a", Map.of("b", "c")));
// => "a%5Bb%5D=c"

Disable URI encoding for readability:

Kotlin:

QS.encode(
  mapOf("a" to mapOf("b" to "c")),
  EncodeOptions(encode = false)
)
// => "a[b]=c"

Java:

QS.encode(
  Map.of("a", Map.of("b", "c")),
  EncodeOptions.builder()
    .encode(false)
    .build()
);
// => "a[b]=c"

Values-only encoding:

Kotlin:

QS.encode(
  mapOf(
    "a" to "b",
    "c" to listOf("d", "e=f"),
    "f" to listOf(listOf("g"), listOf("h")),
  ),
  EncodeOptions(encodeValuesOnly = true)
)
// => "a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h"

Java:

Map<String, Object> map = new LinkedHashMap<>();
map.put("a", "b");
map.put("c", List.of("d", "e=f"));
map.put("f", List.of(List.of("g"), List.of("h")));

QS.encode(
  map,
  EncodeOptions.builder()
    .encodeValuesOnly(true)
    .build()
);
// => "a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h"

Custom encoder:

Kotlin:

QS.encode(
  mapOf("a" to mapOf("b" to "č")),
  EncodeOptions(
    encoder = { v, _, _ -> if (v == "č") "c" else v.toString() }
  )
)
// => "a[b]=c"   (with encode=false would be unescaped)

Java:

JValueEncoder enc = (v, cs, f) -> Objects.equals(v, "č") ? "c" : Objects.toString(v, "");

QS.encode(
  Map.of("a", Map.of("b", "č")),
  EncodeOptions.builder()
    .encoder(enc)
    .build()
);
// => "a%5Bb%5D=c"

List formats

Kotlin:

// default (indices)
QS.encode(
  mapOf("a" to listOf("b", "c")),
  EncodeOptions(encode = false)
)
// => "a[0]=b&a[1]=c"

// brackets
QS.encode(
  mapOf("a" to listOf("b", "c")),
  EncodeOptions(
    encode = false,
    listFormat = ListFormat.BRACKETS
  )
)
// => "a[]=b&a[]=c"

// repeat
QS.encode(
  mapOf("a" to listOf("b", "c")),
  EncodeOptions(
    encode = false,
    listFormat = ListFormat.REPEAT
  )
)
// => "a=b&a=c"

// comma
QS.encode(
  mapOf("a" to listOf("b", "c")),
  EncodeOptions(
    encode = false,
    listFormat = ListFormat.COMMA
  )
)
// => "a=b,c"

Java:

QS.encode(
  Map.of("a", List.of("b","c")),
  EncodeOptions.builder()
    .encode(false)
    .listFormat(ListFormat.INDICES)
    .build()
);
// => "a[0]=b&a[1]=c"

QS.encode(
  Map.of("a", List.of("b","c")),
  EncodeOptions.builder()
    .encode(false)
    .listFormat(ListFormat.BRACKETS)
    .build()
);
// => "a[]=b&a[]=c"

QS.encode(
  Map.of("a", List.of("b","c")),
  EncodeOptions.builder()
    .encode(false)
    .listFormat(ListFormat.REPEAT)
    .build()
);
// => "a=b&a=c"

QS.encode(
  Map.of("a", List.of("b","c")),
  EncodeOptions.builder()
    .encode(false)
    .listFormat(ListFormat.COMMA)
    .build()
);
// => "a=b,c"

Note: When ListFormat.COMMA is selected, you can also set EncodeOptions.commaRoundTrip to true or false to append [] on single-element lists so they round-trip through decoding. Set EncodeOptions.commaCompactNulls to true alongside the comma format when you'd like to drop null entries instead of preserving empty slots (for example, listOf("one", null, "two") becomes one,two).

Nested maps

Kotlin:

QS.encode(
  mapOf("a" to mapOf("b" to mapOf("c" to "d", "e" to "f"))),
  EncodeOptions(encode = false)
)
// => "a[b][c]=d&a[b][e]=f"

Java:

Map<String, Object> inner = new LinkedHashMap<>();
inner.put("c","d"); inner.put("e","f");
Map<String, Object> mid = new LinkedHashMap<>(); mid.put("b", inner);
Map<String, Object> root = new LinkedHashMap<>(); root.put("a", mid);

QS.encode(
  root,
  EncodeOptions.builder()
    .encode(false)
    .build()
);
// => "a[b][c]=d&a[b][e]=f"

Dot notation:

Kotlin:

QS.encode(
  mapOf("a" to mapOf("b" to mapOf("c" to "d", "e" to "f"))),
  EncodeOptions(
    encode = false,
    allowDots = true
  )
)
// => "a.b.c=d&a.b.e=f"

Java:

QS.encode(
  root,
  EncodeOptions.builder()
    .allowDots(true)
    .encode(false)
    .build()
);
// => "a.b.c=d&a.b.e=f"

Encode dots in keys:

Kotlin:

QS.encode(
  mapOf("name.obj" to mapOf("first" to "John", "last" to "Doe")),
  EncodeOptions(
    allowDots = true,
    encodeDotInKeys = true
  )
)
// => "name%252Eobj.first=John&name%252Eobj.last=Doe"

Java:

QS.encode(
  Map.of("name.obj", Map.of("first","John","last","Doe")),
  EncodeOptions.builder()
    .allowDots(true)
    .encodeDotInKeys(true)
    .build()
);
// => "name%252Eobj.first=John&name%252Eobj.last=Doe"

Allow empty lists:

Kotlin:

QS.encode(
  mapOf("foo" to emptyList<String>(), "bar" to "baz"),
  EncodeOptions(
    encode = false,
    allowEmptyLists = true
  )
)
// => "foo[]&bar=baz"

Java:

Map<String, Object> emptyMap = new LinkedHashMap<>();
emptyMap.put("foo", List.of()); emptyMap.put("bar", "baz");

QS.encode(
  emptyMap,
  EncodeOptions.builder()
    .allowEmptyLists(true)
    .encode(false)
    .build()
);
// => "foo[]&bar=baz"

Empty strings & nulls:

Kotlin:

QS.encode(mapOf("a" to ""))
// => "a="

Java:

QS.encode(Map.of("a", ""));
// => "a="

Return empty string for empty containers:

Kotlin:

QS.encode(mapOf("a" to emptyList<String>()))                   // => ""
QS.encode(mapOf("a" to emptyMap<String, Any>()))               // => ""
QS.encode(mapOf("a" to listOf(emptyMap<String, Any>())))       // => ""
QS.encode(mapOf("a" to mapOf("b" to emptyList<String>())))     // => ""
QS.encode(mapOf("a" to mapOf("b" to emptyMap<String, Any>()))) // => ""

Java:

QS.encode(Map.of("a", List.of()));              // => ""
QS.encode(Map.of("a", Map.of()));               // => ""
QS.encode(Map.of("a", List.of(Map.of())));      // => ""
QS.encode(Map.of("a", Map.of("b", List.of()))); // => ""
QS.encode(Map.of("a", Map.of("b", Map.of())));  // => ""

Omit Undefined:

Kotlin:

QS.encode(mapOf("a" to null, "b" to Undefined()))
// => "a="

Java:

Map<String, Object> omit = new LinkedHashMap<>();
omit.put("a", null); omit.put("b", Undefined.INSTANCE);

QS.encode(omit);
// => "a="

Add query prefix:

Kotlin:

QS.encode(
  mapOf("a" to "b", "c" to "d"),
  EncodeOptions(addQueryPrefix = true)
)
// => "?a=b&c=d"

Java:

QS.encode(
  Map.of("a","b","c","d"),
  EncodeOptions.builder()
    .addQueryPrefix(true)
    .build()
);
// => "?a=b&c=d"

Custom delimiter:

Kotlin:

QS.encode(
  mapOf("a" to "b", "c" to "d"),
  EncodeOptions(delimiter = Delimiter.SEMICOLON)
)
// => "a=b;c=d"

Java:

QS.encode(
  Map.of("a","b","c","d"),
  EncodeOptions.builder()
    .delimiter(Delimiter.SEMICOLON)
    .build()
);
// => "a=b;c=d"

Dates

By default, LocalDateTime is serialized using toString().

Kotlin:

val date = java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(7), java.time.ZoneId.systemDefault())

QS.encode(mapOf("a" to date), EncodeOptions(encode = false))
// => "a=1970-01-01T01:00:00.007" (example output depends on system zone)

QS.encode(
  mapOf("a" to date),
  EncodeOptions(
    encode = false,
    dateSerializer = { d -> d.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli().toString() }
  )
)
// => "a=7"

Java:

var date = java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(7), java.time.ZoneId.systemDefault());

QS.encode(
  Map.of("a", date),
  EncodeOptions.builder()
    .encode(false)
    .build()
);
// => "a=1970-01-01T01:00:00.007" (example output depends on system zone)

QS.encode(
  Map.of("a", date),
  EncodeOptions.builder()
    .encode(false)
    .dateSerializer(d -> Long.toString(d.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()))
    .build()
);
// => "a=7"

Sorting & filtering

Kotlin:

// Sort keys
QS.encode(
  mapOf("a" to "c", "z" to "y", "b" to "f"),
  EncodeOptions(
    encode = false,
    sort = { a, b -> a.toString().compareTo(b.toString()) }
  )
)
// => "a=c&b=f&z=y"

// Filter by function (drop/transform values)
QS.encode(
  mapOf("a" to "b", "c" to "d", "e" to mapOf("f" to java.time.Instant.ofEpochMilli(123), "g" to listOf(2))),
  EncodeOptions(
    encode = false,
    filter = FunctionFilter { prefix, value ->
      when (prefix) {
        "b" -> Undefined()
        "e[f]" -> (value as java.time.Instant).toEpochMilli()
        "e[g][0]" -> (value as Number).toInt() * 2
        else -> value
      }
    }
  )
)
// => "a=b&c=d&e[f]=123&e[g][0]=4"

// Filter by explicit list of keys/indices
QS.encode(
  mapOf("a" to "b", "c" to "d", "e" to "f"),
  EncodeOptions(
    encode = false,
    filter = IterableFilter(listOf("a", "e"))
  )
)
// => "a=b&e=f"

QS.encode(
  mapOf("a" to listOf("b", "c", "d"), "e" to "f"),
  EncodeOptions(
    encode = false,
    filter = IterableFilter(listOf("a", 0, 2))
  )
)
// => "a[0]=b&a[2]=d"

Java:

// Sort keys
QS.encode(
  Map.of("a","c","z","y","b","f"),
  EncodeOptions.builder()
    .encode(false)
    .sort(Comparator.comparing(o -> o.toString()))
    .build()
);
// => "a=c&b=f&z=y"

// Function filter
Map<String, Object> input = new LinkedHashMap<>();
input.put("a","b"); input.put("c","d");

Map<String, Object> eMap = new LinkedHashMap<>();
eMap.put("f", java.time.Instant.ofEpochMilli(123));
eMap.put("g", List.of(2));
input.put("e", eMap);

FunctionFilter fn = FunctionFilter.from((k,v) -> switch(k) {
    case "b" -> Undefined.INSTANCE;
    case "e[f]" -> ((java.time.Instant)v).toEpochMilli();
    case "e[g][0]" -> ((Number)v).intValue()*2; 
    default -> v;
});

QS.encode(
  input,
  EncodeOptions.builder()
    .encode(false)
    .filter(fn)
    .build()
);
// => "a=b&c=d&e[f]=123&e[g][0]=4"

// Iterable filters
QS.encode(
  Map.of("a","b","c","d","e","f"),
  EncodeOptions.builder()
    .encode(false)
    .filter(new IterableFilter(List.of("a","e")))
    .build()
);
// => "a=b&e=f"

QS.encode(
  Map.of("a", List.of("b","c","d"), "e","f"),
  EncodeOptions.builder()
    .encode(false)
    .filter(new IterableFilter(List.of("a",0,2)))
    .build()
);
// => "a[0]=b&a[2]=d"

RFC 3986 vs RFC 1738 space encoding

Kotlin:

QS.encode(mapOf("a" to "b c"))
// => "a=b%20c" (RFC 3986 default)

QS.encode(
  mapOf("a" to "b c"),
  EncodeOptions(format = Format.RFC3986)
)
// => "a=b%20c"

QS.encode(
  mapOf("a" to "b c"),
  EncodeOptions(format = Format.RFC1738)
)
// => "a=b+c"

Java:

QS.encode(Map.of("a","b c"));
// => "a=b%20c" (RFC 3986 default)

QS.encode(
  Map.of("a","b c"),
  EncodeOptions.builder()
    .format(Format.RFC3986)
    .build()
);
// => "a=b%20c"

QS.encode(
  Map.of("a","b c"),
  EncodeOptions.builder()
    .format(Format.RFC1738)
    .build()
);
// => "a=b+c"

Design notes

  • Performance: The implementation mirrors qs semantics but is optimized for Kotlin/JVM. Deep parsing, list compaction, and cycle-safe compaction are implemented iteratively where it matters.
  • Safety: Defaults (depth, parameterLimit) help mitigate abuse in user-supplied inputs; you can loosen them when you fully trust the source.
  • Interop: Exposes knobs similar to qs (filters, sorters, custom encoders/decoders) to make migrations straightforward.

Special thanks to the authors of qs for JavaScript:


Other ports

Port Repository Package
Dart techouse/qs pub.dev
Python techouse/qs_codec PyPI
Swift / Objective-C techouse/qs-swift SPM
.NET / C# techouse/qs-net NuGet
Node.js (original) ljharb/qs npm

License

BSD 3-Clause © techouse

About

A query string encoding and decoding library for Android and Kotlin/JVM. Ported from qs for JavaScript.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Sponsor this project

  •  

Packages

No packages published

Contributors 3

  •  
  •  
  •  

Languages