api_bones/url.rs
1//! Fluent URL and query-string builders.
2//!
3//! # [`UrlBuilder`]
4//!
5//! A fluent builder for constructing URLs from scheme, host, path segments,
6//! query parameters, and fragment. Path segments are percent-encoded; query
7//! values are form-encoded.
8//!
9//! ```rust
10//! use api_bones::url::UrlBuilder;
11//!
12//! let url = UrlBuilder::new()
13//! .scheme("https")
14//! .host("api.example.com")
15//! .path("v1")
16//! .path("users")
17//! .path("42")
18//! .query("active", "true")
19//! .build();
20//!
21//! assert_eq!(url, "https://api.example.com/v1/users/42?active=true");
22//! ```
23//!
24//! # [`QueryBuilder`]
25//!
26//! A standalone query-string builder with typed `Display` values and optional
27//! merge into an existing URL.
28//!
29//! ```rust
30//! use api_bones::url::QueryBuilder;
31//!
32//! let qs = QueryBuilder::new()
33//! .param("limit", 20u32)
34//! .param("sort", "desc")
35//! .build();
36//! assert_eq!(qs, "limit=20&sort=desc");
37//! ```
38
39#[cfg(all(not(feature = "std"), feature = "alloc"))]
40use alloc::{
41 string::{String, ToString},
42 vec::Vec,
43};
44use core::fmt;
45#[cfg(feature = "serde")]
46use serde::{Deserialize, Serialize};
47
48// ---------------------------------------------------------------------------
49// Percent-encoding helpers
50// ---------------------------------------------------------------------------
51
52/// Percent-encode a string using the path-segment allowed set (RFC 3986 §3.3).
53///
54/// Unreserved characters (`A-Z a-z 0-9 - . _ ~`) and sub-delimiters
55/// (`: @ ! $ & ' ( ) * + , ; =`) are left as-is. Everything else is encoded.
56#[must_use]
57fn percent_encode_path(s: &str) -> String {
58 let mut out = String::with_capacity(s.len());
59 for byte in s.bytes() {
60 if byte.is_ascii_alphanumeric()
61 || matches!(
62 byte,
63 b'-' | b'.'
64 | b'_'
65 | b'~'
66 | b':'
67 | b'@'
68 | b'!'
69 | b'$'
70 | b'&'
71 | b'\''
72 | b'('
73 | b')'
74 | b'*'
75 | b'+'
76 | b','
77 | b';'
78 | b'='
79 )
80 {
81 out.push(byte as char);
82 } else {
83 let _ = core::fmt::write(&mut out, format_args!("%{byte:02X}"));
84 }
85 }
86 out
87}
88
89/// Percent-encode a query key or value (application/x-www-form-urlencoded style).
90///
91/// Space is encoded as `+`; everything else outside the unreserved set is `%XX`.
92#[must_use]
93fn percent_encode_query(s: &str) -> String {
94 let mut out = String::with_capacity(s.len());
95 for byte in s.bytes() {
96 match byte {
97 b' ' => out.push('+'),
98 b if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') => {
99 out.push(byte as char);
100 }
101 _ => {
102 let _ = core::fmt::write(&mut out, format_args!("%{byte:02X}"));
103 }
104 }
105 }
106 out
107}
108
109// ---------------------------------------------------------------------------
110// UrlBuilder
111// ---------------------------------------------------------------------------
112
113/// Fluent URL builder.
114///
115/// Build a URL incrementally by chaining setter methods, then call [`build`](Self::build)
116/// to produce the final `String`.
117///
118/// Path segments are automatically percent-encoded. Query parameters are
119/// form-encoded. No validation of scheme or host is performed — this is a
120/// string-composition helper, not a full URL parser.
121#[derive(Debug, Clone, Default)]
122#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
123pub struct UrlBuilder {
124 scheme: Option<String>,
125 host: Option<String>,
126 port: Option<u16>,
127 segments: Vec<String>,
128 query: Vec<(String, String)>,
129 fragment: Option<String>,
130}
131
132impl UrlBuilder {
133 /// Create an empty `UrlBuilder`.
134 #[must_use]
135 pub fn new() -> Self {
136 Self::default()
137 }
138
139 /// Set the URL scheme (e.g. `"https"`).
140 ///
141 /// # Examples
142 ///
143 /// ```
144 /// use api_bones::url::UrlBuilder;
145 ///
146 /// let url = UrlBuilder::new().scheme("https").host("example.com").build();
147 /// assert_eq!(url, "https://example.com");
148 /// ```
149 #[must_use]
150 pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
151 self.scheme = Some(scheme.into());
152 self
153 }
154
155 /// Set the host (e.g. `"api.example.com"`).
156 #[must_use]
157 pub fn host(mut self, host: impl Into<String>) -> Self {
158 self.host = Some(host.into());
159 self
160 }
161
162 /// Set an optional port number.
163 ///
164 /// # Examples
165 ///
166 /// ```
167 /// use api_bones::url::UrlBuilder;
168 ///
169 /// let url = UrlBuilder::new()
170 /// .scheme("http")
171 /// .host("localhost")
172 /// .port(8080)
173 /// .build();
174 /// assert_eq!(url, "http://localhost:8080");
175 /// ```
176 #[must_use]
177 pub fn port(mut self, port: u16) -> Self {
178 self.port = Some(port);
179 self
180 }
181
182 /// Append a path segment (will be percent-encoded).
183 ///
184 /// Call multiple times to build up `/a/b/c` style paths.
185 ///
186 /// # Examples
187 ///
188 /// ```
189 /// use api_bones::url::UrlBuilder;
190 ///
191 /// let url = UrlBuilder::new()
192 /// .scheme("https")
193 /// .host("example.com")
194 /// .path("v1")
195 /// .path("users")
196 /// .path("hello world")
197 /// .build();
198 /// assert_eq!(url, "https://example.com/v1/users/hello%20world");
199 /// ```
200 #[must_use]
201 pub fn path(mut self, segment: impl Into<String>) -> Self {
202 self.segments.push(segment.into());
203 self
204 }
205
206 /// Append a query parameter (key and value are form-encoded).
207 ///
208 /// # Examples
209 ///
210 /// ```
211 /// use api_bones::url::UrlBuilder;
212 ///
213 /// let url = UrlBuilder::new()
214 /// .scheme("https")
215 /// .host("example.com")
216 /// .query("q", "hello world")
217 /// .build();
218 /// assert_eq!(url, "https://example.com?q=hello+world");
219 /// ```
220 #[must_use]
221 #[allow(clippy::needless_pass_by_value)]
222 pub fn query(mut self, key: impl Into<String>, value: impl ToString) -> Self {
223 self.query.push((key.into(), value.to_string()));
224 self
225 }
226
227 /// Set the URL fragment (the part after `#`).
228 ///
229 /// # Examples
230 ///
231 /// ```
232 /// use api_bones::url::UrlBuilder;
233 ///
234 /// let url = UrlBuilder::new()
235 /// .scheme("https")
236 /// .host("example.com")
237 /// .fragment("section-1")
238 /// .build();
239 /// assert_eq!(url, "https://example.com#section-1");
240 /// ```
241 #[must_use]
242 pub fn fragment(mut self, fragment: impl Into<String>) -> Self {
243 self.fragment = Some(fragment.into());
244 self
245 }
246
247 /// Produce the final URL string.
248 ///
249 /// # Examples
250 ///
251 /// ```
252 /// use api_bones::url::UrlBuilder;
253 ///
254 /// let url = UrlBuilder::new()
255 /// .scheme("https")
256 /// .host("api.example.com")
257 /// .path("v1")
258 /// .path("items")
259 /// .query("page", 2u32)
260 /// .fragment("top")
261 /// .build();
262 ///
263 /// assert_eq!(url, "https://api.example.com/v1/items?page=2#top");
264 /// ```
265 #[must_use]
266 pub fn build(&self) -> String {
267 let mut out = String::new();
268
269 // scheme://host[:port]
270 if let Some(scheme) = &self.scheme {
271 out.push_str(scheme);
272 out.push_str("://");
273 }
274 if let Some(host) = &self.host {
275 out.push_str(host);
276 }
277 if let Some(port) = self.port {
278 let _ = core::fmt::write(&mut out, format_args!(":{port}"));
279 }
280
281 // /path/segments
282 for seg in &self.segments {
283 out.push('/');
284 out.push_str(&percent_encode_path(seg));
285 }
286
287 // ?key=value&…
288 for (i, (k, v)) in self.query.iter().enumerate() {
289 out.push(if i == 0 { '?' } else { '&' });
290 out.push_str(&percent_encode_query(k));
291 out.push('=');
292 out.push_str(&percent_encode_query(v));
293 }
294
295 // #fragment
296 if let Some(frag) = &self.fragment {
297 out.push('#');
298 out.push_str(frag);
299 }
300
301 out
302 }
303}
304
305impl fmt::Display for UrlBuilder {
306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307 f.write_str(&self.build())
308 }
309}
310
311// ---------------------------------------------------------------------------
312// QueryBuilder
313// ---------------------------------------------------------------------------
314
315/// Standalone query-string builder with typed `Display` values.
316///
317/// Produces `key=value` pairs separated by `&`, with form-encoding applied to
318/// both key and value. Use [`merge_into`](Self::merge_into) to append the
319/// query string to an existing URL.
320///
321/// # Examples
322///
323/// ```
324/// use api_bones::url::QueryBuilder;
325///
326/// let qs = QueryBuilder::new()
327/// .param("limit", 20u32)
328/// .param("sort", "desc")
329/// .build();
330/// assert_eq!(qs, "limit=20&sort=desc");
331/// ```
332#[derive(Debug, Clone, Default)]
333#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
334pub struct QueryBuilder {
335 params: Vec<(String, String)>,
336}
337
338impl QueryBuilder {
339 /// Create an empty `QueryBuilder`.
340 #[must_use]
341 pub fn new() -> Self {
342 Self::default()
343 }
344
345 /// Append a typed query parameter.
346 ///
347 /// The value is converted to a string via [`Display`](core::fmt::Display).
348 ///
349 /// # Examples
350 ///
351 /// ```
352 /// use api_bones::url::QueryBuilder;
353 ///
354 /// let qs = QueryBuilder::new().param("active", true).build();
355 /// assert_eq!(qs, "active=true");
356 /// ```
357 #[must_use]
358 #[allow(clippy::needless_pass_by_value)]
359 pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
360 self.params.push((key.into(), value.to_string()));
361 self
362 }
363
364 /// Append an optional parameter — skipped if `value` is `None`.
365 ///
366 /// # Examples
367 ///
368 /// ```
369 /// use api_bones::url::QueryBuilder;
370 ///
371 /// let qs = QueryBuilder::new()
372 /// .param("a", 1u32)
373 /// .maybe_param("b", None::<&str>)
374 /// .build();
375 /// assert_eq!(qs, "a=1");
376 /// ```
377 #[must_use]
378 pub fn maybe_param(self, key: impl Into<String>, value: Option<impl ToString>) -> Self {
379 match value {
380 Some(v) => self.param(key, v),
381 None => self,
382 }
383 }
384
385 /// Build the query string (without leading `?`).
386 ///
387 /// Returns an empty string when no parameters have been added.
388 #[must_use]
389 pub fn build(&self) -> String {
390 let mut out = String::new();
391 for (i, (k, v)) in self.params.iter().enumerate() {
392 if i > 0 {
393 out.push('&');
394 }
395 out.push_str(&percent_encode_query(k));
396 out.push('=');
397 out.push_str(&percent_encode_query(v));
398 }
399 out
400 }
401
402 /// Append the query string to `url`, using `?` if there is no existing
403 /// query, or `&` if one already exists.
404 ///
405 /// Returns `url` unchanged when there are no params.
406 ///
407 /// # Examples
408 ///
409 /// ```
410 /// use api_bones::url::QueryBuilder;
411 ///
412 /// let qs = QueryBuilder::new().param("page", 2u32);
413 /// assert_eq!(qs.merge_into("https://example.com"), "https://example.com?page=2");
414 /// assert_eq!(qs.merge_into("https://example.com?limit=20"), "https://example.com?limit=20&page=2");
415 /// ```
416 #[must_use]
417 pub fn merge_into(&self, url: &str) -> String {
418 let qs = self.build();
419 if qs.is_empty() {
420 return url.to_string();
421 }
422 let sep = if url.contains('?') { '&' } else { '?' };
423 let mut out = String::with_capacity(url.len() + 1 + qs.len());
424 out.push_str(url);
425 out.push(sep);
426 out.push_str(&qs);
427 out
428 }
429
430 /// Append a key=value pair — alias for [`param`](Self::param).
431 ///
432 /// # Examples
433 ///
434 /// ```
435 /// use api_bones::url::QueryBuilder;
436 ///
437 /// let qs = QueryBuilder::new().set("limit", 10u32).set("sort", "asc").build();
438 /// assert_eq!(qs, "limit=10&sort=asc");
439 /// ```
440 #[must_use]
441 #[allow(clippy::needless_pass_by_value)]
442 pub fn set(self, key: impl Into<String>, value: impl ToString) -> Self {
443 self.param(key, value)
444 }
445
446 /// Append an optional key=value pair — skipped when `value` is `None`.
447 ///
448 /// Alias for [`maybe_param`](Self::maybe_param).
449 ///
450 /// # Examples
451 ///
452 /// ```
453 /// use api_bones::url::QueryBuilder;
454 ///
455 /// let qs = QueryBuilder::new()
456 /// .set("a", 1u32)
457 /// .set_opt("b", None::<&str>)
458 /// .set_opt("c", Some("yes"))
459 /// .build();
460 /// assert_eq!(qs, "a=1&c=yes");
461 /// ```
462 #[must_use]
463 pub fn set_opt(self, key: impl Into<String>, value: Option<impl ToString>) -> Self {
464 self.maybe_param(key, value)
465 }
466
467 /// Flatten a serializable struct's top-level fields as query parameters.
468 ///
469 /// The struct is serialized to a JSON object; each field whose value is not
470 /// `null` is appended as a `key=value` pair. Nested objects and arrays are
471 /// serialized as their JSON representation.
472 ///
473 /// Returns an error when `value` cannot be serialized or is not a JSON object.
474 ///
475 /// # Examples
476 ///
477 /// ```
478 /// use api_bones::url::QueryBuilder;
479 /// use serde::Serialize;
480 ///
481 /// #[derive(Serialize)]
482 /// struct Params {
483 /// page: u32,
484 /// sort: &'static str,
485 /// filter: Option<&'static str>,
486 /// }
487 ///
488 /// let params = Params { page: 2, sort: "desc", filter: None };
489 /// let qs = QueryBuilder::new()
490 /// .extend_from_struct(¶ms)
491 /// .unwrap()
492 /// .build();
493 /// assert_eq!(qs, "page=2&sort=desc");
494 /// ```
495 #[cfg(feature = "serde")]
496 pub fn extend_from_struct<T: serde::Serialize>(
497 mut self,
498 value: &T,
499 ) -> Result<Self, serde_json::Error> {
500 let json = serde_json::to_value(value)?;
501 if let serde_json::Value::Object(map) = json {
502 for (k, v) in map {
503 match v {
504 serde_json::Value::Null => {}
505 serde_json::Value::String(s) => {
506 self.params.push((k, s));
507 }
508 other => {
509 self.params.push((k, other.to_string()));
510 }
511 }
512 }
513 }
514 Ok(self)
515 }
516
517 /// Append the query string to `url` — alias for [`merge_into`](Self::merge_into).
518 ///
519 /// Uses `?` if the URL has no existing query string, or `&` otherwise.
520 /// Returns `url` unchanged when there are no params.
521 ///
522 /// # Examples
523 ///
524 /// ```
525 /// use api_bones::url::QueryBuilder;
526 ///
527 /// let qs = QueryBuilder::new().set("page", 3u32);
528 /// assert_eq!(qs.merge_into_url("https://api.example.com/items"), "https://api.example.com/items?page=3");
529 /// assert_eq!(qs.merge_into_url("https://api.example.com/items?limit=10"), "https://api.example.com/items?limit=10&page=3");
530 /// ```
531 #[must_use]
532 pub fn merge_into_url(&self, url: &str) -> String {
533 self.merge_into(url)
534 }
535
536 /// Return `true` when no parameters have been added.
537 #[must_use]
538 pub fn is_empty(&self) -> bool {
539 self.params.is_empty()
540 }
541}
542
543impl fmt::Display for QueryBuilder {
544 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
545 f.write_str(&self.build())
546 }
547}
548
549// ---------------------------------------------------------------------------
550// Tests
551// ---------------------------------------------------------------------------
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 // --- percent_encode_path ---
558
559 #[test]
560 fn encode_path_alphanumeric_unchanged() {
561 assert_eq!(percent_encode_path("hello123"), "hello123");
562 }
563
564 #[test]
565 fn encode_path_space_encoded() {
566 assert_eq!(percent_encode_path("hello world"), "hello%20world");
567 }
568
569 #[test]
570 fn encode_path_slash_encoded() {
571 assert_eq!(percent_encode_path("a/b"), "a%2Fb");
572 }
573
574 // --- percent_encode_query ---
575
576 #[test]
577 fn encode_query_space_as_plus() {
578 assert_eq!(percent_encode_query("hello world"), "hello+world");
579 }
580
581 #[test]
582 fn encode_query_ampersand() {
583 assert_eq!(percent_encode_query("a&b"), "a%26b");
584 }
585
586 // --- UrlBuilder ---
587
588 #[test]
589 fn full_url() {
590 let url = UrlBuilder::new()
591 .scheme("https")
592 .host("api.example.com")
593 .path("v1")
594 .path("users")
595 .path("42")
596 .query("active", "true")
597 .fragment("top")
598 .build();
599 assert_eq!(url, "https://api.example.com/v1/users/42?active=true#top");
600 }
601
602 #[test]
603 fn url_with_port() {
604 let url = UrlBuilder::new()
605 .scheme("http")
606 .host("localhost")
607 .port(8080)
608 .path("health")
609 .build();
610 assert_eq!(url, "http://localhost:8080/health");
611 }
612
613 #[test]
614 fn url_path_encoding() {
615 let url = UrlBuilder::new()
616 .scheme("https")
617 .host("example.com")
618 .path("hello world")
619 .build();
620 assert_eq!(url, "https://example.com/hello%20world");
621 }
622
623 #[test]
624 fn url_multiple_query_params() {
625 let url = UrlBuilder::new()
626 .scheme("https")
627 .host("example.com")
628 .query("a", 1u32)
629 .query("b", 2u32)
630 .build();
631 assert_eq!(url, "https://example.com?a=1&b=2");
632 }
633
634 #[test]
635 fn url_no_scheme_no_host() {
636 let url = UrlBuilder::new().path("v1").path("items").build();
637 assert_eq!(url, "/v1/items");
638 }
639
640 #[test]
641 fn display_matches_build() {
642 let b = UrlBuilder::new().scheme("https").host("example.com");
643 assert_eq!(b.to_string(), b.build());
644 }
645
646 // --- QueryBuilder ---
647
648 #[test]
649 fn query_builder_basic() {
650 let qs = QueryBuilder::new()
651 .param("limit", 20u32)
652 .param("sort", "desc")
653 .build();
654 assert_eq!(qs, "limit=20&sort=desc");
655 }
656
657 #[test]
658 fn query_builder_empty() {
659 let qs = QueryBuilder::new().build();
660 assert!(qs.is_empty());
661 }
662
663 #[test]
664 fn query_builder_maybe_param_some() {
665 let qs = QueryBuilder::new()
666 .maybe_param("after", Some("cursor123"))
667 .build();
668 assert_eq!(qs, "after=cursor123");
669 }
670
671 #[test]
672 fn query_builder_maybe_param_none() {
673 let qs = QueryBuilder::new()
674 .param("a", 1u32)
675 .maybe_param("b", None::<&str>)
676 .build();
677 assert_eq!(qs, "a=1");
678 }
679
680 #[test]
681 fn merge_into_no_existing_query() {
682 let qs = QueryBuilder::new().param("page", 2u32);
683 assert_eq!(
684 qs.merge_into("https://example.com"),
685 "https://example.com?page=2"
686 );
687 }
688
689 #[test]
690 fn merge_into_existing_query() {
691 let qs = QueryBuilder::new().param("page", 2u32);
692 assert_eq!(
693 qs.merge_into("https://example.com?limit=20"),
694 "https://example.com?limit=20&page=2"
695 );
696 }
697
698 #[test]
699 fn merge_into_empty_returns_url_unchanged() {
700 let qs = QueryBuilder::new();
701 assert_eq!(qs.merge_into("https://example.com"), "https://example.com");
702 }
703
704 #[test]
705 fn query_builder_url_encodes_special_chars() {
706 let qs = QueryBuilder::new().param("q", "hello world&more").build();
707 assert_eq!(qs, "q=hello+world%26more");
708 }
709
710 // --- UrlBuilder Default ---
711
712 #[test]
713 fn url_builder_default_produces_empty_string() {
714 let b = UrlBuilder::default();
715 assert_eq!(b.build(), "");
716 }
717
718 // --- QueryBuilder Display ---
719
720 #[test]
721 fn query_builder_display_matches_build() {
722 let qb = QueryBuilder::new()
723 .param("limit", 10u32)
724 .param("sort", "asc");
725 assert_eq!(qb.to_string(), qb.build());
726 }
727
728 // --- QueryBuilder is_empty ---
729
730 #[test]
731 fn query_builder_is_empty_true_when_no_params() {
732 assert!(QueryBuilder::new().is_empty());
733 }
734
735 #[test]
736 fn query_builder_is_empty_false_after_param() {
737 assert!(!QueryBuilder::new().param("k", "v").is_empty());
738 }
739
740 // --- merge_into with empty QueryBuilder ---
741
742 #[test]
743 fn merge_into_empty_no_change() {
744 let qb = QueryBuilder::default();
745 assert_eq!(
746 qb.merge_into("https://example.com/path"),
747 "https://example.com/path"
748 );
749 }
750
751 // --- set / set_opt / merge_into_url ---
752
753 #[test]
754 fn set_appends_param() {
755 let qs = QueryBuilder::new()
756 .set("limit", 5u32)
757 .set("sort", "asc")
758 .build();
759 assert_eq!(qs, "limit=5&sort=asc");
760 }
761
762 #[test]
763 fn set_opt_skips_none() {
764 let qs = QueryBuilder::new()
765 .set("a", 1u32)
766 .set_opt("b", None::<&str>)
767 .set_opt("c", Some("yes"))
768 .build();
769 assert_eq!(qs, "a=1&c=yes");
770 }
771
772 #[test]
773 fn merge_into_url_no_existing_query() {
774 let qs = QueryBuilder::new().set("page", 3u32);
775 assert_eq!(
776 qs.merge_into_url("https://example.com"),
777 "https://example.com?page=3"
778 );
779 }
780
781 #[test]
782 fn merge_into_url_with_existing_query() {
783 let qs = QueryBuilder::new().set("page", 3u32);
784 assert_eq!(
785 qs.merge_into_url("https://example.com?limit=10"),
786 "https://example.com?limit=10&page=3"
787 );
788 }
789
790 #[test]
791 fn merge_into_url_empty_unchanged() {
792 let qs = QueryBuilder::new();
793 assert_eq!(
794 qs.merge_into_url("https://example.com"),
795 "https://example.com"
796 );
797 }
798
799 // --- extend_from_struct ---
800
801 #[cfg(feature = "serde")]
802 #[test]
803 fn extend_from_struct_basic() {
804 use serde::Serialize;
805
806 #[derive(Serialize)]
807 struct Params {
808 page: u32,
809 sort: &'static str,
810 filter: Option<&'static str>,
811 }
812
813 let params = Params {
814 page: 2,
815 sort: "desc",
816 filter: None,
817 };
818 let qs = QueryBuilder::new()
819 .extend_from_struct(¶ms)
820 .unwrap()
821 .build();
822 // page and sort should appear; filter (None) should be omitted
823 assert!(qs.contains("page=2"), "expected page=2 in {qs}");
824 assert!(qs.contains("sort=desc"), "expected sort=desc in {qs}");
825 assert!(!qs.contains("filter"), "filter should be omitted from {qs}");
826 }
827
828 #[cfg(feature = "serde")]
829 #[test]
830 fn extend_from_struct_preserves_existing_params() {
831 use serde::Serialize;
832
833 #[derive(Serialize)]
834 struct Extra {
835 q: &'static str,
836 }
837
838 let qs = QueryBuilder::new()
839 .set("limit", 10u32)
840 .extend_from_struct(&Extra { q: "rust" })
841 .unwrap()
842 .build();
843 assert!(qs.starts_with("limit=10"), "existing param first: {qs}");
844 assert!(qs.contains("q=rust"), "struct field present: {qs}");
845 }
846}