use url::form_urlencoded;
pub struct UrlParamsBuilder {
params: Vec<(String, String)>,
}
impl UrlParamsBuilder {
pub fn new() -> Self {
Self { params: Vec::new() }
}
pub fn add(mut self, key: &str, value: &str) -> Self {
self.params.push((key.to_string(), value.to_string()));
self
}
pub fn add_optional(mut self, key: &str, value: Option<&str>) -> Self {
if let Some(v) = value {
self.params.push((key.to_string(), v.to_string()));
}
self
}
pub fn finish(self) -> String {
let mut serializer = form_urlencoded::Serializer::new(String::new());
for (key, value) in self.params {
serializer.append_pair(&key, &value);
}
serializer.finish()
}
}
impl Default for UrlParamsBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_params_builder_basic() {
let params = UrlParamsBuilder::new()
.add("offset", "0")
.add("limit", "50")
.finish();
assert_eq!(params, "offset=0&limit=50");
}
#[test]
fn test_url_params_builder_escaping() {
let params = UrlParamsBuilder::new()
.add("filter", "status=open")
.add("query", "name:\"Test User\"")
.finish();
assert!(params.contains("%3D")); assert!(params.contains("%22")); assert!(params.contains("%3A")); }
#[test]
fn test_url_params_builder_optional_some() {
let params = UrlParamsBuilder::new()
.add("offset", "0")
.add_optional("filter", Some("status=open"))
.finish();
assert!(params.contains("filter"));
assert!(params.contains("status"));
}
#[test]
fn test_url_params_builder_optional_none() {
let params = UrlParamsBuilder::new()
.add("offset", "0")
.add_optional("filter", None)
.finish();
assert!(!params.contains("filter"));
assert_eq!(params, "offset=0");
}
#[test]
fn test_url_params_builder_empty() {
let params = UrlParamsBuilder::new().finish();
assert_eq!(params, "");
}
#[test]
fn test_url_params_builder_special_chars() {
let params = UrlParamsBuilder::new()
.add("key", "value with spaces & special=chars")
.finish();
assert!(params.contains("%20") || params.contains('+'));
assert!(params.contains("%26"));
}
#[test]
fn test_url_params_injection_attempt() {
let malicious = "value&extra_param=injected";
let params = UrlParamsBuilder::new().add("safe_key", malicious).finish();
assert!(params.contains("%26extra_param"));
assert!(!params.contains("&extra_param="));
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn encoding_never_panics(
key in "\\PC{1,50}",
value in "\\PC*"
) {
let _ = UrlParamsBuilder::new().add(&key, &value).finish();
}
#[test]
fn no_unencoded_special_chars(
key in "[a-z]+",
value in ".*"
) {
let params = UrlParamsBuilder::new().add(&key, &value).finish();
let parts: Vec<&str> = params.split('&').collect();
for part in parts {
if let Some(value_part) = part.split('=').nth(1) {
prop_assert!(!value_part.contains('&'));
}
}
}
#[test]
fn special_chars_encoded(value in "[&=#+]*") {
if !value.is_empty() {
let params = UrlParamsBuilder::new().add("key", &value).finish();
if value.contains('&') {
prop_assert!(params.contains("%26"));
}
if value.contains('=') {
prop_assert!(params.contains("%3D"));
}
if value.contains('#') {
prop_assert!(params.contains("%23"));
}
if value.contains('+') {
prop_assert!(params.contains("%2B"));
}
}
}
#[test]
fn multiple_params_separated(
key1 in "[a-z]+",
value1 in "\\PC{0,20}",
key2 in "[a-z]+",
value2 in "\\PC{0,20}"
) {
let params = UrlParamsBuilder::new()
.add(&key1, &value1)
.add(&key2, &value2)
.finish();
prop_assert!(params.contains(&key1));
prop_assert!(params.contains(&key2));
prop_assert!(params.contains('&'));
}
#[test]
fn optional_none_excluded(
key1 in "[a-z]+",
value1 in "\\PC{0,20}",
key2 in "[a-z]+"
) {
if key1 != key2 && !key1.contains(&key2) {
let params = UrlParamsBuilder::new()
.add(&key1, &value1)
.add_optional(&key2, None)
.finish();
let key1_pattern = format!("{}=", key1);
let key2_pattern = format!("{}=", key2);
prop_assert!(params.contains(&key1_pattern));
prop_assert!(!params.contains(&key2_pattern));
}
}
#[test]
fn empty_builder_empty_output(_i in 0..10u32) {
let params = UrlParamsBuilder::new().finish();
prop_assert_eq!(¶ms, "");
}
}
}
}