| Crates.io | bunner_qs_rs |
| lib.rs | bunner_qs_rs |
| version | 0.1.1 |
| created_at | 2025-10-12 13:39:23.89616+00 |
| updated_at | 2025-10-14 10:51:03.296275+00 |
| description | Fast, secure, and configurable query-string parser and serializer for Rust |
| homepage | https://github.com/parkrevil/bunner-qs-rs |
| repository | https://github.com/parkrevil/bunner-qs-rs |
| max_upload_size | |
| id | 1879294 |
| size | 700,220 |
English | 한국어
bunner-qs-rs is a Rust library for parsing and serializing query strings safely and efficiently.
Deserialize/Serialize type.a[0], a[b][c]) into arrays and maps.a=1&b=two → {"a": "1", "b": "two"}
flag → {"flag": ""}
flag= → {"flag": ""}
name=J%C3%BCrgen → {"name": "Jürgen"}
키=값 → {"키": "값"}
profile[name]=Ada&profile[contacts][email]=ada@example.com&profile[contacts][phones][0]=+44%20123&profile[contacts][phones][1]=+44%20987
→ {"profile": {"name": "Ada", "contacts": {"email": "ada@example.com", "phones": ["+44 123", "+44 987"]}}}
a[b][c]=value → {"a": {"b": {"c": "value"}}}
matrix[0][0]=1&matrix[0][1]=2&matrix[1][0]=3
→ {"matrix": [["1", "2"], ["3"]]}
a[0]=x&a[1]=y → {"a": ["x", "y"]}
a[1]=x → {"a": ["", "x"]}
a[0]=x&a[2]=y → {"a": ["x", "", "y"]}
items[0]=apple&items[2]=cherry → {"items": ["apple", "", "cherry"]}
a[]=x&a[]=y → {"a": ["x", "y"]}
tags[]=rust&tags[]=serde → {"tags": ["rust", "serde"]}
a[][b]=1 → {"a": [{"b": "1"}]}
key[0][a]=1&key[1]=&key[2][b]=2 → {"key": [{"a": "1"}, "", {"b": "2"}]}
# space_as_plus option
(space_as_plus=false) a=hello+world → {"a": "hello+world"}
(space_as_plus=true) a=hello+world → {"a": "hello world"}
[!IMPORTANT] This library does not ship any HTTP server or middleware. Integrate it with the framework you use.
Add the library with cargo add.
cargo add bunner_qs_rs
Or declare it in Cargo.toml as shown below.
[dependencies]
bunner_qs_rs = "0.1.0"
This example parses and serializes a query string.
use bunner_qs_rs::{ParseOptions, StringifyOptions, Qs};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize, Serialize)]
struct Query {
page: u32,
tags: Vec<String>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let qs = Qs::new()
.with_parse(ParseOptions::new().max_depth(5))?
.with_stringify(StringifyOptions::new())?;
let query: Query = qs.parse("page=2&tags[0]=rust&tags[1]=serde")?;
println!("{:?}", query);
let encoded = qs.stringify(&query)?;
println!("{}", encoded);
Ok(())
}
[!TIP] Create the
Qsinstance once during application start-up and reuse it.
Pass the raw query string whether or not it includes ?. An empty string or "?" returns the default value.
use bunner_qs_rs::{ParseOptions, Qs};
let qs = Qs::new()
.with_parse(ParseOptions::new().max_depth(8))?;
let result: serde_json::Value = qs.parse("a=1&b[0]=x&b[1]=y")?;
Options applied when parsing query strings. The defaults are shown below.
| Option | Default | Description |
|---|---|---|
space_as_plus |
false |
Interpret + as a space |
duplicate_keys |
DuplicateKeyBehavior::Reject |
Behavior for repeated keys |
max_params |
None |
Limit the number of key-value pairs |
max_length |
None |
Limit input length (bytes) |
max_depth |
None |
Limit bracket nesting depth |
space_as_plusEnable this when parsing www-form-urlencoded payloads.
let default_options = ParseOptions::new();
// "a=hello+world" → {"a": "hello+world"}
let form_options = ParseOptions::new().space_as_plus(true);
// "a=hello+world" → {"a": "hello world"}
duplicate_keysConfigure how duplicate keys are handled.
Reject (default): return an error if duplicates appear.FirstWins: keep the first duplicate value.LastWins: keep the last duplicate value.let strict = ParseOptions::new()
.duplicate_keys(DuplicateKeyBehavior::Reject);
// "a=1&a=2" → ParseError::DuplicateRootKey
let last_wins = ParseOptions::new()
.duplicate_keys(DuplicateKeyBehavior::LastWins);
// "a=1&a=2" → {"a": "2"}
let first_wins = ParseOptions::new()
.duplicate_keys(DuplicateKeyBehavior::FirstWins);
// "a=1&a=2" → {"a": "1"}
max_paramsLimit how many parameters are accepted.
let options = ParseOptions::new().max_params(128);
max_lengthLimit the maximum length of the input string.
let options = ParseOptions::new().max_length(8 * 1024);
max_depthLimit bracket nesting depth.
let options = ParseOptions::new().max_depth(10);
Deserialize into serde_json::Value.
let json_value: serde_json::Value = qs.parse("items[0]=a&items[1]=b")?;
// json_value = {"items": ["a", "b"]}
Parse directly into any type implementing Deserialize + Default.
use serde::Deserialize;
#[derive(Default, Deserialize)]
struct UserInfo {
name: String,
age: u32,
}
let user: UserInfo = qs.parse("name=Alice&age=30")?;
[!IMPORTANT] When the input is empty (
""or"?"),Qs::parsereturns the target type’sDefaultvalue. Make sure your structs implementDefaultwith sensible defaults for this case.
This example converts Serialize data into a query string.
use serde::Serialize;
#[derive(Serialize)]
struct Query {
search: String,
page: u32,
}
let query = Query { search: "Rust".into(), page: 1 };
let encoded = qs.stringify(&query)?;
// "search=Rust&page=1"
Options applied when turning objects into query strings. Defaults shown below.
| Option | Default | Description |
|---|---|---|
space_as_plus |
false |
Encode spaces as + |
space_as_plusEnable when producing www-form-urlencoded strings.
use bunner_qs_rs::StringifyOptions;
// Default (disabled)
let default_options = StringifyOptions::new();
// {"text": "hello world"} → "text=hello%20world"
// Enabled
let form_options = StringifyOptions::new().space_as_plus(true);
// {"text": "hello world"} → "text=hello+world"
OptionsValidationError is returned when ParseOptions or StringifyOptions contain invalid settings.
| Error | Description |
|---|---|
NonZeroRequired { field } |
max_params, max_length, and max_depth must be greater than zero. |
| Error | Description |
|---|---|
QsParseError::MissingParseOptions |
Parse options were not configured. |
QsParseError::ParseError contains the following variants.
| Error | Description | Examples |
|---|---|---|
ParseError::InputTooLong |
Input exceeds the allowed length | • max_length(3) with aaaa (4 bytes)• max_length(1) with a=1 (3 bytes) |
ParseError::TooManyParameters |
max_params exceeded |
• max_params(1) with a=1&b=2• max_params(2) with x=1&y=2&z=3 |
ParseError::DuplicateRootKey |
Duplicate keys at the root | • duplicate_keys(Reject) + a=1&a=2• duplicate_keys(Reject) + user=foo&user=bar |
ParseError::DuplicateMapEntry |
Duplicate keys under the same parent | • a[b]=1&a[b]=2• profile[contacts][email]=a@x.io&profile[contacts][email]=b@y.io |
ParseError::DuplicateSequenceIndex |
Duplicate indices under the same parent | • a[0]=x&a[0]=y• items[]=first&items[0]=override |
ParseError::InvalidSequenceIndex |
Index out of the usize range |
• a[18446744073709551616]=1• items[340282366920938463463374607431768211456]=x |
ParseError::NestedValueConflict |
Mixing scalar and structured values | • a=1&a[b]=2• tags[]=rust&tags=legacy |
ParseError::KeyPatternConflict |
Mixing sequences and maps under the same parent | • a[0]=x&a[b]=y• a[]=x&a[foo]=y• profile[user]=id&profile[0]=guest |
ParseError::InvalidPercentEncoding |
Invalid percent encoding | • a=%ZZ (bad hex)• a=%F (fewer than two digits)• a=100% (missing encoding after %) |
ParseError::InvalidCharacter |
Contains disallowed characters | • a=hello world (unencoded space)• a=%01 (decoded control char)• \u0001=1 (control char in key) |
ParseError::UnexpectedQuestionMark |
Question mark inside the query | • a=1?b=2 (mid-string ?)• ??a=1 (leading ? remains after trimming) |
ParseError::UnmatchedBracket |
Unbalanced brackets | • a[b=1 (missing closing bracket)• a]=1 (closing without opening)• arr[[0]=x (extra [) |
ParseError::DepthExceeded |
Nesting depth exceeded | • max_depth(1) with a[b]=1• max_depth(2) with a[b][c][d]=1 |
ParseError::InvalidUtf8 |
UTF-8 decoding failed | • a=%FF (invalid byte)• name=%E0%80%80 (overlong UTF-8)• title=%C3 (incomplete sequence) |
ParseError::Serde |
Failed to deserialize into the target type | • age=not-a-number for an integer field• active=yes for a boolean field• tags[0]=x into struct { tags: HashMap<_, _> } |
| Error | Description |
|---|---|
QsStringifyError::MissingStringifyOptions |
Stringify options were not configured. |
QsStringifyError::Stringify contains the following variants.
| Error | Description | Examples |
|---|---|---|
StringifyError::Serialize |
Serialize implementation failed |
Types that cannot be serialized |
StringifyError::InvalidKey |
Key contains control characters | Key includes NULL |
StringifyError::InvalidValue |
Value contains control characters | Value includes control characters |
Framework-specific examples live in the /examples directory.
cargo run --example axum
curl -v "http://127.0.0.1:5001/items?page=2&per_page=50"
cargo run --example actix
curl -v "http://127.0.0.1:5002/items?page=2&per_page=50"
cargo run --example hyper
curl -v "http://127.0.0.1:5003/items?page=2&per_page=50"
This library includes unit tests, integration tests, property-based tests, and snapshot tests.
# General tests
make test
# Coverage
make coverage
# Benchmarks
make bench
We are not accepting contributions for a while. Updates will be posted when we are ready.
Please open an issue if you encounter a problem or have a request.
MIT License. See LICENSE.md for details.