blockless_sdk/
http.rs

1use crate::rpc::{JsonRpcResponse, RpcClient, RpcError};
2use std::collections::HashMap;
3
4const MAX_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10MB max response
5
6// RPC request/response structures for HTTP
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
8pub struct HttpRpcRequest {
9    pub url: String,
10    pub options: HttpOptions,
11}
12
13#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
14#[serde(untagged)]
15pub enum HttpBody {
16    Text(String),
17    Binary(Vec<u8>),
18    Form(HashMap<String, String>),
19    Multipart(Vec<MultipartField>),
20}
21
22#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
23pub struct HttpOptions {
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub method: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub headers: Option<HashMap<String, String>>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub body: Option<HttpBody>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub timeout: Option<u32>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub query_params: Option<HashMap<String, String>>,
34}
35
36impl Default for HttpOptions {
37    fn default() -> Self {
38        Self {
39            method: Some("GET".to_string()),
40            headers: None,
41            body: None,
42            timeout: Some(30000), // 30 seconds default
43            query_params: None,
44        }
45    }
46}
47
48impl HttpOptions {
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    pub fn method<S: Into<String>>(mut self, method: S) -> Self {
54        self.method = Some(method.into());
55        self
56    }
57
58    pub fn header<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
59        if self.headers.is_none() {
60            self.headers = Some(HashMap::new());
61        }
62        self.headers
63            .as_mut()
64            .unwrap()
65            .insert(key.into(), value.into());
66        self
67    }
68
69    pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
70        self.headers = Some(headers);
71        self
72    }
73
74    pub fn body<S: Into<String>>(mut self, body: S) -> Self {
75        self.body = Some(HttpBody::Text(body.into()));
76        self
77    }
78
79    pub fn body_binary(mut self, data: Vec<u8>) -> Self {
80        self.body = Some(HttpBody::Binary(data));
81        self
82    }
83
84    pub fn form(mut self, form_data: HashMap<String, String>) -> Self {
85        self.body = Some(HttpBody::Form(form_data));
86        self = self.header("Content-Type", "application/x-www-form-urlencoded");
87        self
88    }
89
90    pub fn multipart(mut self, fields: Vec<MultipartField>) -> Self {
91        self.body = Some(HttpBody::Multipart(fields));
92        // Note: Content-Type with boundary will be set by the host function
93        self
94    }
95
96    pub fn timeout(mut self, timeout_ms: u32) -> Self {
97        self.timeout = Some(timeout_ms);
98        self
99    }
100
101    pub fn json<T: serde::Serialize>(mut self, data: &T) -> Result<Self, HttpError> {
102        let json_body = serde_json::to_string(data).map_err(|_| HttpError::SerializationError)?;
103        self.body = Some(HttpBody::Text(json_body));
104        self = self.header("Content-Type", "application/json");
105        Ok(self)
106    }
107
108    pub fn basic_auth<U: Into<String>, P: Into<String>>(self, username: U, password: P) -> Self {
109        let credentials = format!("{}:{}", username.into(), password.into());
110        let encoded = base64::encode_config(credentials.as_bytes(), base64::STANDARD);
111        self.header("Authorization", format!("Basic {}", encoded))
112    }
113
114    pub fn bearer_auth<T: Into<String>>(self, token: T) -> Self {
115        self.header("Authorization", format!("Bearer {}", token.into()))
116    }
117
118    pub fn query_param<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
119        if self.query_params.is_none() {
120            self.query_params = Some(HashMap::new());
121        }
122        self.query_params
123            .as_mut()
124            .unwrap()
125            .insert(key.into(), value.into());
126        self
127    }
128
129    pub fn query_params(mut self, params: HashMap<String, String>) -> Self {
130        self.query_params = Some(params);
131        self
132    }
133}
134
135#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
136pub enum MultipartValue {
137    Text(String),
138    Binary {
139        data: Vec<u8>,
140        filename: Option<String>,
141        content_type: Option<String>,
142    },
143}
144
145#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
146pub struct MultipartField {
147    pub name: String,
148    pub value: MultipartValue,
149}
150
151impl MultipartField {
152    pub fn text<N: Into<String>, V: Into<String>>(name: N, value: V) -> Self {
153        Self {
154            name: name.into(),
155            value: MultipartValue::Text(value.into()),
156        }
157    }
158
159    pub fn binary<N: Into<String>>(
160        name: N,
161        data: Vec<u8>,
162        filename: Option<String>,
163        content_type: Option<String>,
164    ) -> Self {
165        Self {
166            name: name.into(),
167            value: MultipartValue::Binary {
168                data,
169                filename,
170                content_type,
171            },
172        }
173    }
174
175    pub fn file<N: Into<String>, F: Into<String>>(
176        name: N,
177        data: Vec<u8>,
178        filename: F,
179        content_type: Option<String>,
180    ) -> Self {
181        Self {
182            name: name.into(),
183            value: MultipartValue::Binary {
184                data,
185                filename: Some(filename.into()),
186                content_type,
187            },
188        }
189    }
190}
191
192#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
193pub struct HttpResponse {
194    pub status: u16,
195    pub headers: HashMap<String, String>,
196    pub body: Vec<u8>,
197    pub url: String,
198}
199
200impl HttpResponse {
201    pub fn text(&self) -> Result<String, HttpError> {
202        String::from_utf8(self.body.clone()).map_err(|_| HttpError::Utf8Error)
203    }
204
205    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, HttpError> {
206        let text = self.text()?;
207        serde_json::from_str(&text).map_err(|_| HttpError::JsonParseError)
208    }
209
210    pub fn bytes(&self) -> &[u8] {
211        &self.body
212    }
213
214    pub fn status(&self) -> u16 {
215        self.status
216    }
217
218    pub fn is_success(&self) -> bool {
219        self.status >= 200 && self.status < 300
220    }
221
222    pub fn headers(&self) -> &HashMap<String, String> {
223        &self.headers
224    }
225
226    pub fn header(&self, name: &str) -> Option<&String> {
227        self.headers.get(name)
228    }
229
230    pub fn url(&self) -> &str {
231        &self.url
232    }
233}
234
235#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
236pub struct HttpResult {
237    pub success: bool,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub data: Option<HttpResponse>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub error: Option<String>,
242}
243
244#[derive(Debug, Clone)]
245pub enum HttpError {
246    InvalidUrl,
247    SerializationError,
248    JsonParseError,
249    Utf8Error,
250    EmptyResponse,
251    RequestFailed(String),
252    NetworkError,
253    Timeout,
254    RpcError(RpcError),
255    Unknown(u32),
256}
257
258impl std::fmt::Display for HttpError {
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        match self {
261            HttpError::InvalidUrl => write!(f, "Invalid URL provided"),
262            HttpError::SerializationError => write!(f, "Failed to serialize request data"),
263            HttpError::JsonParseError => write!(f, "Failed to parse JSON response"),
264            HttpError::Utf8Error => write!(f, "Invalid UTF-8 in response"),
265            HttpError::EmptyResponse => write!(f, "Empty response received"),
266            HttpError::RequestFailed(msg) => write!(f, "Request failed: {}", msg),
267            HttpError::NetworkError => write!(f, "Network error occurred"),
268            HttpError::Timeout => write!(f, "Request timed out"),
269            HttpError::RpcError(e) => write!(f, "RPC error: {}", e),
270            HttpError::Unknown(code) => write!(f, "Unknown error (code: {})", code),
271        }
272    }
273}
274
275impl From<RpcError> for HttpError {
276    fn from(e: RpcError) -> Self {
277        HttpError::RpcError(e)
278    }
279}
280
281impl std::error::Error for HttpError {}
282
283pub struct HttpClient {
284    default_headers: Option<HashMap<String, String>>,
285    timeout: Option<u32>,
286}
287
288impl Default for HttpClient {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294impl Clone for HttpClient {
295    fn clone(&self) -> Self {
296        Self {
297            default_headers: self.default_headers.clone(),
298            timeout: self.timeout,
299        }
300    }
301}
302
303impl HttpClient {
304    pub fn new() -> Self {
305        Self {
306            default_headers: None,
307            timeout: Some(30000), // 30 seconds default
308        }
309    }
310
311    pub fn builder() -> HttpClientBuilder {
312        HttpClientBuilder::new()
313    }
314
315    // HTTP verb methods - return RequestBuilder for chaining
316    pub fn get<U: Into<String>>(&self, url: U) -> RequestBuilder {
317        self.request("GET", url)
318    }
319
320    pub fn post<U: Into<String>>(&self, url: U) -> RequestBuilder {
321        self.request("POST", url)
322    }
323
324    pub fn put<U: Into<String>>(&self, url: U) -> RequestBuilder {
325        self.request("PUT", url)
326    }
327
328    pub fn patch<U: Into<String>>(&self, url: U) -> RequestBuilder {
329        self.request("PATCH", url)
330    }
331
332    pub fn delete<U: Into<String>>(&self, url: U) -> RequestBuilder {
333        self.request("DELETE", url)
334    }
335
336    pub fn head<U: Into<String>>(&self, url: U) -> RequestBuilder {
337        self.request("HEAD", url)
338    }
339
340    pub fn request<U: Into<String>>(&self, method: &str, url: U) -> RequestBuilder {
341        let mut headers = HashMap::new();
342        if let Some(ref default_headers) = self.default_headers {
343            headers.extend(default_headers.clone());
344        }
345
346        RequestBuilder {
347            client: self.clone(),
348            method: method.to_string(),
349            url: url.into(),
350            headers,
351            query_params: HashMap::new(),
352            body: None,
353            timeout: self.timeout,
354        }
355    }
356
357    fn execute(&self, builder: &RequestBuilder) -> Result<HttpResponse, HttpError> {
358        let options = HttpOptions {
359            method: Some(builder.method.clone()),
360            headers: if builder.headers.is_empty() {
361                None
362            } else {
363                Some(builder.headers.clone())
364            },
365            body: builder.body.clone(),
366            timeout: builder.timeout,
367            query_params: if builder.query_params.is_empty() {
368                None
369            } else {
370                Some(builder.query_params.clone())
371            },
372        };
373
374        self.make_request(&builder.url, options)
375    }
376
377    fn make_request(&self, url: &str, options: HttpOptions) -> Result<HttpResponse, HttpError> {
378        if url.is_empty() {
379            return Err(HttpError::InvalidUrl);
380        }
381
382        // Build final URL with query parameters
383        let final_url = if let Some(ref params) = options.query_params {
384            build_url_with_params(url, params)
385        } else {
386            url.to_string()
387        };
388
389        let request = HttpRpcRequest {
390            url: final_url,
391            options,
392        };
393        let mut rpc_client = RpcClient::with_buffer_size(MAX_RESPONSE_SIZE);
394        let response: JsonRpcResponse<HttpResult> =
395            rpc_client.call("http.request", Some(request))?;
396
397        if let Some(error) = response.error {
398            return Err(HttpError::RequestFailed(format!(
399                "RPC error: {} (code: {})",
400                error.message, error.code
401            )));
402        }
403        let http_result = response.result.ok_or(HttpError::EmptyResponse)?;
404
405        if !http_result.success {
406            let error_msg = http_result
407                .error
408                .unwrap_or_else(|| "Unknown error".to_string());
409            return Err(HttpError::RequestFailed(error_msg));
410        }
411
412        http_result.data.ok_or(HttpError::EmptyResponse)
413    }
414}
415
416pub struct HttpClientBuilder {
417    default_headers: Option<HashMap<String, String>>,
418    timeout: Option<u32>,
419}
420
421impl Default for HttpClientBuilder {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427impl HttpClientBuilder {
428    pub fn new() -> Self {
429        Self {
430            default_headers: None,
431            timeout: Some(30000),
432        }
433    }
434
435    pub fn default_headers(mut self, headers: HashMap<String, String>) -> Self {
436        self.default_headers = Some(headers);
437        self
438    }
439
440    pub fn timeout(mut self, timeout: u32) -> Self {
441        self.timeout = Some(timeout);
442        self
443    }
444
445    pub fn build(self) -> HttpClient {
446        HttpClient {
447            default_headers: self.default_headers,
448            timeout: self.timeout,
449        }
450    }
451}
452pub struct RequestBuilder {
453    client: HttpClient,
454    method: String,
455    url: String,
456    headers: HashMap<String, String>,
457    query_params: HashMap<String, String>,
458    body: Option<HttpBody>,
459    timeout: Option<u32>,
460}
461
462impl RequestBuilder {
463    pub fn header<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
464        self.headers.insert(key.into(), value.into());
465        self
466    }
467
468    pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
469        self.headers.extend(headers);
470        self
471    }
472
473    pub fn query<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
474        self.query_params.insert(key.into(), value.into());
475        self
476    }
477
478    pub fn query_params(mut self, params: HashMap<String, String>) -> Self {
479        self.query_params.extend(params);
480        self
481    }
482
483    pub fn basic_auth<U: Into<String>, P: Into<String>>(
484        mut self,
485        username: U,
486        password: P,
487    ) -> Self {
488        let credentials = format!("{}:{}", username.into(), password.into());
489        let encoded = base64::encode_config(credentials.as_bytes(), base64::STANDARD);
490        self.headers
491            .insert("Authorization".to_string(), format!("Basic {}", encoded));
492        self
493    }
494
495    pub fn bearer_auth<T: Into<String>>(mut self, token: T) -> Self {
496        self.headers.insert(
497            "Authorization".to_string(),
498            format!("Bearer {}", token.into()),
499        );
500        self
501    }
502
503    pub fn timeout(mut self, timeout: u32) -> Self {
504        self.timeout = Some(timeout);
505        self
506    }
507
508    pub fn body<S: Into<String>>(mut self, body: S) -> Self {
509        self.body = Some(HttpBody::Text(body.into()));
510        self
511    }
512
513    pub fn body_bytes(mut self, body: Vec<u8>) -> Self {
514        self.body = Some(HttpBody::Binary(body));
515        self
516    }
517
518    pub fn form(mut self, form: HashMap<String, String>) -> Self {
519        self.body = Some(HttpBody::Form(form));
520        self.headers.insert(
521            "Content-Type".to_string(),
522            "application/x-www-form-urlencoded".to_string(),
523        );
524        self
525    }
526
527    pub fn multipart(mut self, form: Vec<MultipartField>) -> Self {
528        self.body = Some(HttpBody::Multipart(form));
529        self
530    }
531
532    pub fn json<T: serde::Serialize>(mut self, json: &T) -> Result<Self, HttpError> {
533        let json_body = serde_json::to_string(json).map_err(|_| HttpError::SerializationError)?;
534        self.body = Some(HttpBody::Text(json_body));
535        self.headers
536            .insert("Content-Type".to_string(), "application/json".to_string());
537        Ok(self)
538    }
539
540    pub fn send(self) -> Result<HttpResponse, HttpError> {
541        self.client.execute(&self)
542    }
543}
544
545// ====================
546// Utility Functions
547// ====================
548
549pub fn build_url_with_params(base_url: &str, params: &HashMap<String, String>) -> String {
550    if params.is_empty() {
551        return base_url.to_string();
552    }
553    match url::Url::parse(base_url) {
554        Ok(mut url) => {
555            for (key, value) in params {
556                url.query_pairs_mut().append_pair(key, value);
557            }
558            url.to_string()
559        }
560        Err(_) => {
561            // Fallback for invalid URLs - append query parameters manually
562            let mut url = base_url.to_string();
563            let separator = if url.contains('?') { '&' } else { '?' };
564            url.push(separator);
565
566            let encoded_params: Vec<String> = params
567                .iter()
568                .map(|(k, v)| {
569                    format!(
570                        "{}={}",
571                        url::form_urlencoded::byte_serialize(k.as_bytes()).collect::<String>(),
572                        url::form_urlencoded::byte_serialize(v.as_bytes()).collect::<String>()
573                    )
574                })
575                .collect();
576            url.push_str(&encoded_params.join("&"));
577            url
578        }
579    }
580}
581
582// ====================
583// Module-level Convenience Functions
584// ====================
585
586pub fn get<U: Into<String>>(url: U) -> RequestBuilder {
587    HttpClient::new().get(url)
588}
589
590pub fn post<U: Into<String>>(url: U) -> RequestBuilder {
591    HttpClient::new().post(url)
592}
593
594pub fn put<U: Into<String>>(url: U) -> RequestBuilder {
595    HttpClient::new().put(url)
596}
597
598pub fn patch<U: Into<String>>(url: U) -> RequestBuilder {
599    HttpClient::new().patch(url)
600}
601
602pub fn delete<U: Into<String>>(url: U) -> RequestBuilder {
603    HttpClient::new().delete(url)
604}
605
606pub fn head<U: Into<String>>(url: U) -> RequestBuilder {
607    HttpClient::new().head(url)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn test_deserialize_array_body() {
616        let json_str = r#"{"success":true,"data":{"status":200,"headers":{"content-type":"application/json"},"body":[123,34,104,101,108,108,111,34,58,34,119,111,114,108,100,34,125],"url":"https://httpbin.org/get"}}"#;
617
618        let result: HttpResult = serde_json::from_str(json_str).unwrap();
619        assert!(result.success);
620
621        let response = result.data.unwrap();
622        assert_eq!(response.status, 200);
623
624        // The body should be: {"hello":"world"}
625        let expected_body = b"{\"hello\":\"world\"}";
626        assert_eq!(response.body, expected_body);
627
628        let body_text = response.text().unwrap();
629        assert_eq!(body_text, "{\"hello\":\"world\"}");
630    }
631
632    #[test]
633    fn test_multipart_field_creation() {
634        let text_field = MultipartField::text("name", "value");
635        assert_eq!(text_field.name, "name");
636        match text_field.value {
637            MultipartValue::Text(ref v) => assert_eq!(v, "value"),
638            _ => panic!("Expected text value"),
639        }
640
641        let binary_field =
642            MultipartField::binary("file", vec![1, 2, 3], Some("test.bin".to_string()), None);
643        assert_eq!(binary_field.name, "file");
644        match binary_field.value {
645            MultipartValue::Binary {
646                ref data,
647                ref filename,
648                ..
649            } => {
650                assert_eq!(data, &vec![1, 2, 3]);
651                assert_eq!(filename.as_ref().unwrap(), "test.bin");
652            }
653            _ => panic!("Expected binary value"),
654        }
655    }
656
657    #[test]
658    fn test_url_building() {
659        let mut params = HashMap::new();
660        params.insert("key1".to_string(), "value1".to_string());
661        params.insert("key2".to_string(), "value with spaces".to_string());
662
663        let url = build_url_with_params("https://example.com/api", &params);
664        assert!(url.contains("key1=value1"));
665        assert!(url.contains("key2=value+with+spaces"));
666        assert!(url.starts_with("https://example.com/api?"));
667    }
668
669    #[test]
670    fn test_url_building_special_chars() {
671        let mut params = HashMap::new();
672        params.insert("special".to_string(), "!@#$%^&*()".to_string());
673        params.insert("utf8".to_string(), "こんにちは".to_string());
674        params.insert("reserved".to_string(), "test&foo=bar".to_string());
675
676        let url = build_url_with_params("https://example.com/api", &params);
677
678        // Check that special characters are properly encoded
679        // Note: url crate uses + for spaces and different encoding for some chars
680        assert!(url.contains("special=%21%40%23%24%25%5E%26*%28%29"));
681        assert!(url.contains("reserved=test%26foo%3Dbar"));
682        // UTF-8 characters should be percent-encoded
683        assert!(url.contains("utf8=%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF"));
684    }
685
686    #[test]
687    fn test_url_building_with_existing_query() {
688        let mut params = HashMap::new();
689        params.insert("new_param".to_string(), "new_value".to_string());
690
691        let url = build_url_with_params("https://example.com/api?existing=param", &params);
692        assert!(url.contains("existing=param"));
693        assert!(url.contains("new_param=new_value"));
694        assert!(url.contains("&"));
695    }
696
697    #[test]
698    fn test_url_building_empty_params() {
699        let params = HashMap::new();
700        let url = build_url_with_params("https://example.com/api", &params);
701        assert_eq!(url, "https://example.com/api");
702    }
703
704    #[test]
705    fn test_client_builder() {
706        let mut headers = HashMap::new();
707        headers.insert("User-Agent".to_string(), "Blockless-SDK/1.0".to_string());
708
709        let client = HttpClient::builder()
710            .default_headers(headers)
711            .timeout(10000)
712            .build();
713
714        assert!(client.default_headers.is_some());
715        assert_eq!(client.timeout, Some(10000));
716    }
717
718    #[test]
719    fn test_request_builder() {
720        let client = HttpClient::new();
721        let request = client
722            .post("https://httpbin.org/post")
723            .header("Content-Type", "application/json")
724            .query("search", "test")
725            .query("limit", "10")
726            .body("test body")
727            .timeout(5000);
728
729        assert_eq!(request.method, "POST");
730        assert_eq!(request.url, "https://httpbin.org/post");
731        assert_eq!(
732            request.headers.get("Content-Type").unwrap(),
733            "application/json"
734        );
735        assert_eq!(request.query_params.get("search").unwrap(), "test");
736        assert_eq!(request.query_params.get("limit").unwrap(), "10");
737        assert_eq!(request.timeout, Some(5000));
738
739        match request.body.as_ref().unwrap() {
740            HttpBody::Text(ref body) => assert_eq!(body, "test body"),
741            _ => panic!("Expected text body"),
742        }
743    }
744
745    #[test]
746    fn test_basic_auth() {
747        let client = HttpClient::new();
748        let request = client
749            .get("https://httpbin.org/basic-auth/user/pass")
750            .basic_auth("username", "password");
751
752        let auth_header = request.headers.get("Authorization").unwrap();
753        assert!(auth_header.starts_with("Basic "));
754
755        // Verify it's properly base64 encoded "username:password"
756        let encoded_part = &auth_header[6..]; // Remove "Basic " prefix
757        let decoded = base64::decode_config(encoded_part, base64::STANDARD).unwrap();
758        let decoded_str = String::from_utf8(decoded).unwrap();
759        assert_eq!(decoded_str, "username:password");
760    }
761
762    #[test]
763    fn test_bearer_auth() {
764        let client = HttpClient::new();
765        let request = client
766            .get("https://httpbin.org/bearer")
767            .bearer_auth("test-token-123");
768
769        let auth_header = request.headers.get("Authorization").unwrap();
770        assert_eq!(auth_header, "Bearer test-token-123");
771    }
772
773    #[test]
774    fn test_query_params_integration() {
775        let mut params1 = HashMap::new();
776        params1.insert("base".to_string(), "param".to_string());
777
778        let client = HttpClient::new();
779        let request = client
780            .get("https://api.example.com/search")
781            .query_params(params1)
782            .query("additional", "value")
783            .query("special chars", "test & encode");
784
785        assert_eq!(request.query_params.get("base").unwrap(), "param");
786        assert_eq!(request.query_params.get("additional").unwrap(), "value");
787        assert_eq!(
788            request.query_params.get("special chars").unwrap(),
789            "test & encode"
790        );
791
792        // Test URL building
793        let url = build_url_with_params("https://api.example.com/search", &request.query_params);
794        assert!(url.contains("base=param"));
795        assert!(url.contains("additional=value"));
796        assert!(url.contains("special+chars=test+%26+encode"));
797    }
798
799    #[test]
800    fn test_module_level_functions() {
801        // Test that module-level convenience functions work
802        let _get_request = get("https://httpbin.org/get");
803        let _post_request = post("https://httpbin.org/post");
804        let _put_request = put("https://httpbin.org/put");
805        let _patch_request = patch("https://httpbin.org/patch");
806        let _delete_request = delete("https://httpbin.org/delete");
807
808        // These should all return RequestBuilder objects
809        let request = get("https://httpbin.org/get")
810            .query("test", "value")
811            .header("User-Agent", "test");
812
813        assert_eq!(request.method, "GET");
814        assert_eq!(request.url, "https://httpbin.org/get");
815        assert_eq!(request.query_params.get("test").unwrap(), "value");
816        assert_eq!(request.headers.get("User-Agent").unwrap(), "test");
817    }
818}