bunner_qs_rs

Crates.iobunner_qs_rs
lib.rsbunner_qs_rs
version0.1.1
created_at2025-10-12 13:39:23.89616+00
updated_at2025-10-14 10:51:03.296275+00
descriptionFast, secure, and configurable query-string parser and serializer for Rust
homepagehttps://github.com/parkrevil/bunner-qs-rs
repositoryhttps://github.com/parkrevil/bunner-qs-rs
max_upload_size
id1879294
size700,220
박준형 (parkrevil)

documentation

https://github.com/parkrevil/bunner-qs-rs

README

bunner-qs-rs

Crates.io version tests coverage License

English | 한국어


✨ Introduction

bunner-qs-rs is a Rust library for parsing and serializing query strings safely and efficiently.

  • Serde integration: Natural round trips with any Deserialize/Serialize type.
  • Nested structure support: Converts bracket notation (a[0], a[b][c]) into arrays and maps.
  • Guardrails: Enforce limits on length, parameter count, and nesting depth with grammar validation.
  • Policy control: Configure whitespace handling, duplicate-key behavior, and limit options.
  • Standards compliance: Full RFC 3986 percent-encoding support.

✅ Supported formats

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.


📚 Table of contents


🚀 Getting started

Installation

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"

Quick start

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 Qs instance once during application start-up and reuse it.


🧩 Parsing

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")?;

ParseOptions

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_plus

Enable 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_keys

Configure 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_params

Limit how many parameters are accepted.

let options = ParseOptions::new().max_params(128);

max_length

Limit the maximum length of the input string.

let options = ParseOptions::new().max_length(8 * 1024);

max_depth

Limit bracket nesting depth.

let options = ParseOptions::new().max_depth(10);

Results

Convert to JSON

Deserialize into serde_json::Value.

let json_value: serde_json::Value = qs.parse("items[0]=a&items[1]=b")?;
// json_value = {"items": ["a", "b"]}

Convert to a Serde struct

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::parse returns the target type’s Default value. Make sure your structs implement Default with sensible defaults for this case.


📦 Stringifying

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"

StringifyOptions

Options applied when turning objects into query strings. Defaults shown below.

Option Default Description
space_as_plus false Encode spaces as +

space_as_plus

Enable 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"

🚨 Errors

Configuration validation

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.

Parsing errors

Configuration errors

Error Description
QsParseError::MissingParseOptions Parse options were not configured.

Runtime errors

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<_, _> }

Stringifying errors

Configuration errors

Error Description
QsStringifyError::MissingStringifyOptions Stringify options were not configured.

Runtime errors

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

📝 Examples

Framework-specific examples live in the /examples directory.

axum

cargo run --example axum
curl -v "http://127.0.0.1:5001/items?page=2&per_page=50"

Actix Web

cargo run --example actix
curl -v "http://127.0.0.1:5002/items?page=2&per_page=50"

hyper

cargo run --example hyper
curl -v "http://127.0.0.1:5003/items?page=2&per_page=50"

Tests

This library includes unit tests, integration tests, property-based tests, and snapshot tests.

# General tests
make test

# Coverage
make coverage

# Benchmarks
make bench

❤️ Contributing

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.


📜 License

MIT License. See LICENSE.md for details.

Commit count: 0

cargo fmt