#[cfg(all(not(feature = "std"), feature = "alloc"))]
use alloc::{
string::{String, ToString},
vec::Vec,
};
use core::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[must_use]
fn percent_encode_path(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
if byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'-' | b'.'
| b'_'
| b'~'
| b':'
| b'@'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
)
{
out.push(byte as char);
} else {
let _ = core::fmt::write(&mut out, format_args!("%{byte:02X}"));
}
}
out
}
#[must_use]
fn percent_encode_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b' ' => out.push('+'),
b if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') => {
out.push(byte as char);
}
_ => {
let _ = core::fmt::write(&mut out, format_args!("%{byte:02X}"));
}
}
}
out
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct UrlBuilder {
scheme: Option<String>,
host: Option<String>,
port: Option<u16>,
segments: Vec<String>,
query: Vec<(String, String)>,
fragment: Option<String>,
}
impl UrlBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
self.scheme = Some(scheme.into());
self
}
#[must_use]
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}
#[must_use]
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
#[must_use]
pub fn path(mut self, segment: impl Into<String>) -> Self {
self.segments.push(segment.into());
self
}
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn query(mut self, key: impl Into<String>, value: impl ToString) -> Self {
self.query.push((key.into(), value.to_string()));
self
}
#[must_use]
pub fn fragment(mut self, fragment: impl Into<String>) -> Self {
self.fragment = Some(fragment.into());
self
}
#[must_use]
pub fn build(&self) -> String {
let mut out = String::new();
if let Some(scheme) = &self.scheme {
out.push_str(scheme);
out.push_str("://");
}
if let Some(host) = &self.host {
out.push_str(host);
}
if let Some(port) = self.port {
let _ = core::fmt::write(&mut out, format_args!(":{port}"));
}
for seg in &self.segments {
out.push('/');
out.push_str(&percent_encode_path(seg));
}
for (i, (k, v)) in self.query.iter().enumerate() {
out.push(if i == 0 { '?' } else { '&' });
out.push_str(&percent_encode_query(k));
out.push('=');
out.push_str(&percent_encode_query(v));
}
if let Some(frag) = &self.fragment {
out.push('#');
out.push_str(frag);
}
out
}
}
impl fmt::Display for UrlBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.build())
}
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct QueryBuilder {
params: Vec<(String, String)>,
}
impl QueryBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
self.params.push((key.into(), value.to_string()));
self
}
#[must_use]
pub fn maybe_param(self, key: impl Into<String>, value: Option<impl ToString>) -> Self {
match value {
Some(v) => self.param(key, v),
None => self,
}
}
#[must_use]
pub fn build(&self) -> String {
let mut out = String::new();
for (i, (k, v)) in self.params.iter().enumerate() {
if i > 0 {
out.push('&');
}
out.push_str(&percent_encode_query(k));
out.push('=');
out.push_str(&percent_encode_query(v));
}
out
}
#[must_use]
pub fn merge_into(&self, url: &str) -> String {
let qs = self.build();
if qs.is_empty() {
return url.to_string();
}
let sep = if url.contains('?') { '&' } else { '?' };
let mut out = String::with_capacity(url.len() + 1 + qs.len());
out.push_str(url);
out.push(sep);
out.push_str(&qs);
out
}
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn set(self, key: impl Into<String>, value: impl ToString) -> Self {
self.param(key, value)
}
#[must_use]
pub fn set_opt(self, key: impl Into<String>, value: Option<impl ToString>) -> Self {
self.maybe_param(key, value)
}
#[cfg(feature = "serde")]
pub fn extend_from_struct<T: serde::Serialize>(
mut self,
value: &T,
) -> Result<Self, serde_json::Error> {
let json = serde_json::to_value(value)?;
if let serde_json::Value::Object(map) = json {
for (k, v) in map {
match v {
serde_json::Value::Null => {}
serde_json::Value::String(s) => {
self.params.push((k, s));
}
other => {
self.params.push((k, other.to_string()));
}
}
}
}
Ok(self)
}
#[must_use]
pub fn merge_into_url(&self, url: &str) -> String {
self.merge_into(url)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.params.is_empty()
}
}
impl fmt::Display for QueryBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.build())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_path_alphanumeric_unchanged() {
assert_eq!(percent_encode_path("hello123"), "hello123");
}
#[test]
fn encode_path_space_encoded() {
assert_eq!(percent_encode_path("hello world"), "hello%20world");
}
#[test]
fn encode_path_slash_encoded() {
assert_eq!(percent_encode_path("a/b"), "a%2Fb");
}
#[test]
fn encode_query_space_as_plus() {
assert_eq!(percent_encode_query("hello world"), "hello+world");
}
#[test]
fn encode_query_ampersand() {
assert_eq!(percent_encode_query("a&b"), "a%26b");
}
#[test]
fn full_url() {
let url = UrlBuilder::new()
.scheme("https")
.host("api.example.com")
.path("v1")
.path("users")
.path("42")
.query("active", "true")
.fragment("top")
.build();
assert_eq!(url, "https://api.example.com/v1/users/42?active=true#top");
}
#[test]
fn url_with_port() {
let url = UrlBuilder::new()
.scheme("http")
.host("localhost")
.port(8080)
.path("health")
.build();
assert_eq!(url, "http://localhost:8080/health");
}
#[test]
fn url_path_encoding() {
let url = UrlBuilder::new()
.scheme("https")
.host("example.com")
.path("hello world")
.build();
assert_eq!(url, "https://example.com/hello%20world");
}
#[test]
fn url_multiple_query_params() {
let url = UrlBuilder::new()
.scheme("https")
.host("example.com")
.query("a", 1u32)
.query("b", 2u32)
.build();
assert_eq!(url, "https://example.com?a=1&b=2");
}
#[test]
fn url_no_scheme_no_host() {
let url = UrlBuilder::new().path("v1").path("items").build();
assert_eq!(url, "/v1/items");
}
#[test]
fn display_matches_build() {
let b = UrlBuilder::new().scheme("https").host("example.com");
assert_eq!(b.to_string(), b.build());
}
#[test]
fn query_builder_basic() {
let qs = QueryBuilder::new()
.param("limit", 20u32)
.param("sort", "desc")
.build();
assert_eq!(qs, "limit=20&sort=desc");
}
#[test]
fn query_builder_empty() {
let qs = QueryBuilder::new().build();
assert!(qs.is_empty());
}
#[test]
fn query_builder_maybe_param_some() {
let qs = QueryBuilder::new()
.maybe_param("after", Some("cursor123"))
.build();
assert_eq!(qs, "after=cursor123");
}
#[test]
fn query_builder_maybe_param_none() {
let qs = QueryBuilder::new()
.param("a", 1u32)
.maybe_param("b", None::<&str>)
.build();
assert_eq!(qs, "a=1");
}
#[test]
fn merge_into_no_existing_query() {
let qs = QueryBuilder::new().param("page", 2u32);
assert_eq!(
qs.merge_into("https://example.com"),
"https://example.com?page=2"
);
}
#[test]
fn merge_into_existing_query() {
let qs = QueryBuilder::new().param("page", 2u32);
assert_eq!(
qs.merge_into("https://example.com?limit=20"),
"https://example.com?limit=20&page=2"
);
}
#[test]
fn merge_into_empty_returns_url_unchanged() {
let qs = QueryBuilder::new();
assert_eq!(qs.merge_into("https://example.com"), "https://example.com");
}
#[test]
fn query_builder_url_encodes_special_chars() {
let qs = QueryBuilder::new().param("q", "hello world&more").build();
assert_eq!(qs, "q=hello+world%26more");
}
#[test]
fn url_builder_default_produces_empty_string() {
let b = UrlBuilder::default();
assert_eq!(b.build(), "");
}
#[test]
fn query_builder_display_matches_build() {
let qb = QueryBuilder::new()
.param("limit", 10u32)
.param("sort", "asc");
assert_eq!(qb.to_string(), qb.build());
}
#[test]
fn query_builder_is_empty_true_when_no_params() {
assert!(QueryBuilder::new().is_empty());
}
#[test]
fn query_builder_is_empty_false_after_param() {
assert!(!QueryBuilder::new().param("k", "v").is_empty());
}
#[test]
fn merge_into_empty_no_change() {
let qb = QueryBuilder::default();
assert_eq!(
qb.merge_into("https://example.com/path"),
"https://example.com/path"
);
}
#[test]
fn set_appends_param() {
let qs = QueryBuilder::new()
.set("limit", 5u32)
.set("sort", "asc")
.build();
assert_eq!(qs, "limit=5&sort=asc");
}
#[test]
fn set_opt_skips_none() {
let qs = QueryBuilder::new()
.set("a", 1u32)
.set_opt("b", None::<&str>)
.set_opt("c", Some("yes"))
.build();
assert_eq!(qs, "a=1&c=yes");
}
#[test]
fn merge_into_url_no_existing_query() {
let qs = QueryBuilder::new().set("page", 3u32);
assert_eq!(
qs.merge_into_url("https://example.com"),
"https://example.com?page=3"
);
}
#[test]
fn merge_into_url_with_existing_query() {
let qs = QueryBuilder::new().set("page", 3u32);
assert_eq!(
qs.merge_into_url("https://example.com?limit=10"),
"https://example.com?limit=10&page=3"
);
}
#[test]
fn merge_into_url_empty_unchanged() {
let qs = QueryBuilder::new();
assert_eq!(
qs.merge_into_url("https://example.com"),
"https://example.com"
);
}
#[cfg(feature = "serde")]
#[test]
fn extend_from_struct_basic() {
use serde::Serialize;
#[derive(Serialize)]
struct Params {
page: u32,
sort: &'static str,
filter: Option<&'static str>,
}
let params = Params {
page: 2,
sort: "desc",
filter: None,
};
let qs = QueryBuilder::new()
.extend_from_struct(¶ms)
.unwrap()
.build();
assert!(qs.contains("page=2"), "expected page=2 in {qs}");
assert!(qs.contains("sort=desc"), "expected sort=desc in {qs}");
assert!(!qs.contains("filter"), "filter should be omitted from {qs}");
}
#[cfg(feature = "serde")]
#[test]
fn extend_from_struct_preserves_existing_params() {
use serde::Serialize;
#[derive(Serialize)]
struct Extra {
q: &'static str,
}
let qs = QueryBuilder::new()
.set("limit", 10u32)
.extend_from_struct(&Extra { q: "rust" })
.unwrap()
.build();
assert!(qs.starts_with("limit=10"), "existing param first: {qs}");
assert!(qs.contains("q=rust"), "struct field present: {qs}");
}
}