runtara_agents/agents/
http.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! HTTP agent for making web requests
4//!
5//! This module provides HTTP request operations with support for:
6//! - Multiple HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
7//! - Custom headers and query parameters
8//! - JSON and binary request/response bodies
9//! - Response body as JSON or raw bytes/text
10//!
11//! The actual HTTP execution happens on the host side via host functions,
12//! while this module handles request preparation and response parsing.
13
14use runtara_agent_macro::{CapabilityInput, CapabilityOutput, capability};
15use runtara_dsl::agent_meta::EnumVariants;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::HashMap;
19use strum::VariantNames;
20
21// ============================================================================
22// Enums
23// ============================================================================
24
25/// HTTP method for the request
26#[derive(Debug, Clone, Serialize, Deserialize, VariantNames)]
27#[serde(rename_all = "UPPERCASE")]
28#[strum(serialize_all = "UPPERCASE")]
29pub enum HttpMethod {
30    /// GET request - retrieve data
31    Get,
32    /// POST request - create or submit data
33    Post,
34    /// PUT request - update or replace data
35    Put,
36    /// DELETE request - remove data
37    Delete,
38    /// PATCH request - partially update data
39    Patch,
40    /// HEAD request - retrieve headers only
41    Head,
42    /// OPTIONS request - query supported methods
43    Options,
44}
45
46impl EnumVariants for HttpMethod {
47    fn variant_names() -> &'static [&'static str] {
48        Self::VARIANTS
49    }
50}
51
52impl Default for HttpMethod {
53    fn default() -> Self {
54        Self::Get
55    }
56}
57
58impl HttpMethod {
59    pub fn as_str(&self) -> &str {
60        match self {
61            Self::Get => "GET",
62            Self::Post => "POST",
63            Self::Put => "PUT",
64            Self::Delete => "DELETE",
65            Self::Patch => "PATCH",
66            Self::Head => "HEAD",
67            Self::Options => "OPTIONS",
68        }
69    }
70}
71
72/// Expected format of the HTTP response body
73#[derive(Debug, Clone, Serialize, Deserialize, VariantNames)]
74#[serde(rename_all = "lowercase")]
75#[strum(serialize_all = "lowercase")]
76pub enum ResponseType {
77    /// Parse response as JSON
78    Json,
79    /// Return response as plain text
80    Text,
81    /// Return response as raw binary data
82    Binary,
83}
84
85impl EnumVariants for ResponseType {
86    fn variant_names() -> &'static [&'static str] {
87        Self::VARIANTS
88    }
89}
90
91impl Default for ResponseType {
92    fn default() -> Self {
93        Self::Json
94    }
95}
96
97impl ResponseType {
98    pub fn as_str(&self) -> &str {
99        match self {
100            Self::Json => "json",
101            Self::Text => "text",
102            Self::Binary => "binary",
103        }
104    }
105}
106
107// ============================================================================
108// Input/Output Types
109// ============================================================================
110
111/// Represents the body of an HTTP request
112///
113/// Note: This is now just a Value wrapper to handle all input types uniformly.
114/// The actual conversion to JSON/text/binary happens when sending the request.
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116#[serde(transparent)]
117pub struct HttpBody(pub Value);
118
119impl HttpBody {
120    /// Check if body is empty/null
121    pub fn is_empty(&self) -> bool {
122        self.0.is_null()
123    }
124
125    /// Convert to string for sending in request
126    pub fn to_string_body(&self) -> Option<String> {
127        match &self.0 {
128            Value::Null => None,
129            Value::String(s) if s.is_empty() => None,
130            Value::String(s) => Some(s.clone()),
131            other => Some(other.to_string()),
132        }
133    }
134}
135
136/// Represents the body of an HTTP response
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(untagged)]
139pub enum HttpResponseBody {
140    /// Binary response body (base64 encoded)
141    #[serde(with = "base64_string")]
142    Binary(Vec<u8>),
143    /// Text response body
144    Text(String),
145    /// JSON response body
146    Json(Value),
147}
148
149/// Body type for HTTP requests
150#[derive(Debug, Clone, Serialize, Deserialize, VariantNames)]
151#[serde(rename_all = "lowercase")]
152#[strum(serialize_all = "lowercase")]
153pub enum BodyType {
154    /// JSON body (default)
155    Json,
156    /// Plain text body
157    Text,
158    /// Raw binary body (base64 encoded in input)
159    Binary,
160    /// Multipart form data (for file uploads)
161    Multipart,
162}
163
164impl EnumVariants for BodyType {
165    fn variant_names() -> &'static [&'static str] {
166        Self::VARIANTS
167    }
168}
169
170impl Default for BodyType {
171    fn default() -> Self {
172        Self::Json
173    }
174}
175
176/// A part of a multipart form request
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct MultipartPart {
179    /// Field name
180    pub name: String,
181
182    /// Field value (string) or file data
183    #[serde(flatten)]
184    pub content: MultipartContent,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(untagged)]
189pub enum MultipartContent {
190    /// Text field
191    Text { value: String },
192
193    /// File field (base64 encoded)
194    File {
195        /// Base64 encoded file content
196        content: String,
197        /// Filename for Content-Disposition header
198        #[serde(skip_serializing_if = "Option::is_none")]
199        filename: Option<String>,
200        /// Content-Type for this part
201        #[serde(skip_serializing_if = "Option::is_none")]
202        #[serde(rename = "contentType")]
203        content_type: Option<String>,
204    },
205}
206
207/// Input structure for HTTP request operation
208#[derive(Debug, Serialize, Deserialize, CapabilityInput)]
209#[capability_input(display_name = "HTTP Request Input")]
210pub struct HttpRequestInput {
211    /// HTTP method
212    #[field(
213        display_name = "Method",
214        description = "HTTP verb for the request",
215        example = "GET",
216        default = "GET",
217        enum_type = "HttpMethod"
218    )]
219    #[serde(default)]
220    pub method: HttpMethod,
221
222    /// Target URL
223    #[field(
224        display_name = "URL",
225        description = "Full URL to send the request to",
226        example = "https://api.example.com/v1/users"
227    )]
228    pub url: String,
229
230    /// HTTP headers
231    #[field(
232        display_name = "Headers",
233        description = "Custom HTTP headers",
234        example = r#"{"Authorization": "Bearer token123"}"#,
235        default = "{}"
236    )]
237    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
238    pub headers: HashMap<String, String>,
239
240    /// Query parameters
241    #[field(
242        display_name = "Query Parameters",
243        description = "URL query parameters",
244        example = r#"{"page": "1", "limit": "100"}"#,
245        default = "{}"
246    )]
247    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
248    pub query_parameters: HashMap<String, String>,
249
250    /// Request body
251    #[field(
252        display_name = "Body",
253        description = "Request payload",
254        example = r#"{"name": "John Doe", "email": "john@example.com"}"#,
255        default = "null"
256    )]
257    #[serde(default, skip_serializing_if = "HttpBody::is_empty")]
258    pub body: HttpBody,
259
260    /// Body type for the request
261    #[field(
262        display_name = "Body Type",
263        description = "How to encode the request body",
264        example = "json",
265        default = "json",
266        enum_type = "BodyType"
267    )]
268    #[serde(default)]
269    pub body_type: BodyType,
270
271    /// Multipart form parts (used when body_type is "multipart")
272    #[field(
273        display_name = "Multipart Parts",
274        description = "Form fields and files to include in multipart requests",
275        default = "[]"
276    )]
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub multipart: Vec<MultipartPart>,
279
280    /// Response body type
281    #[field(
282        display_name = "Response Type",
283        description = "Expected response format",
284        example = "json",
285        default = "json",
286        enum_type = "ResponseType"
287    )]
288    #[serde(default)]
289    pub response_type: ResponseType,
290
291    /// Request timeout in milliseconds
292    #[field(
293        display_name = "Timeout (ms)",
294        description = "Maximum time to wait for response",
295        example = "5000",
296        default = "30000"
297    )]
298    #[serde(default = "default_timeout")]
299    pub timeout_ms: u64,
300
301    /// Whether to fail the step on non-2xx responses
302    #[field(
303        display_name = "Fail on Error",
304        description = "If true (default), non-2xx responses will fail the step. If false, non-2xx responses are returned normally.",
305        example = "true",
306        default = "true"
307    )]
308    #[serde(default = "default_fail_on_error")]
309    pub fail_on_error: bool,
310
311    /// Connection data injected by workflow runtime (internal use)
312    #[serde(skip_serializing_if = "Option::is_none")]
313    #[field(skip)]
314    pub _connection: Option<crate::connections::RawConnection>,
315}
316
317impl Default for HttpRequestInput {
318    fn default() -> Self {
319        HttpRequestInput {
320            method: HttpMethod::default(),
321            url: String::new(),
322            headers: HashMap::new(),
323            query_parameters: HashMap::new(),
324            body: HttpBody(Value::Null),
325            response_type: ResponseType::default(),
326            timeout_ms: default_timeout(),
327            body_type: BodyType::default(),
328            multipart: Vec::new(),
329            fail_on_error: default_fail_on_error(),
330            _connection: None,
331        }
332    }
333}
334
335fn default_timeout() -> u64 {
336    30000
337}
338
339fn default_fail_on_error() -> bool {
340    true
341}
342
343/// HTTP response metadata (without body)
344#[derive(Debug, Serialize, Deserialize)]
345#[allow(dead_code)]
346struct HttpResponseMetadata {
347    /// HTTP status code (e.g., 200, 404, 500)
348    pub status_code: u16,
349
350    /// Response headers
351    pub headers: HashMap<String, String>,
352
353    /// Length of the response body in bytes
354    pub body_length: usize,
355
356    /// Response type: "json", "text", or "binary"
357    pub response_type: String,
358
359    /// Whether the request was successful (2xx status code)
360    pub success: bool,
361}
362
363/// HTTP response structure
364#[derive(Debug, Serialize, Deserialize, CapabilityOutput)]
365#[capability_output(
366    display_name = "HTTP Response",
367    description = "Response from an HTTP request"
368)]
369pub struct HttpResponse {
370    #[field(
371        display_name = "Status Code",
372        description = "HTTP status code (e.g., 200, 404, 500)",
373        example = "200"
374    )]
375    pub status_code: u16,
376
377    #[field(
378        display_name = "Headers",
379        description = "Response headers as key-value pairs"
380    )]
381    pub headers: HashMap<String, String>,
382
383    #[field(
384        display_name = "Body",
385        description = "Response body (JSON object, text string, or base64-encoded binary depending on response_type)"
386    )]
387    pub body: HttpResponseBody,
388
389    #[field(
390        display_name = "Success",
391        description = "True if the status code is in the 2xx range",
392        example = "true"
393    )]
394    pub success: bool,
395}
396
397// Re-export HttpConnectionConfig from extractors for convenience
398pub use crate::extractors::HttpConnectionConfig;
399
400/// Extract HTTP connection config from a raw connection using registered extractors
401pub fn extract_connection_config(
402    raw: &crate::connections::RawConnection,
403) -> Result<HttpConnectionConfig, String> {
404    crate::extractors::extract_http_config(
405        &raw.integration_id,
406        &raw.parameters,
407        raw.rate_limit_config.clone(),
408    )
409}
410
411// ============================================================================
412// Operations
413// ============================================================================
414
415/// Execute an HTTP request using async reqwest
416#[capability(
417    module = "http",
418    display_name = "HTTP Request",
419    description = "Execute an HTTP request with the specified method, URL, headers, and body",
420    side_effects = true
421)]
422pub async fn http_request(input: HttpRequestInput) -> Result<HttpResponse, String> {
423    // Start with input values
424    let mut headers = input.headers.clone();
425    let mut query_parameters = input.query_parameters.clone();
426    let mut url = input.url.clone();
427
428    // If connection data is provided, extract config and merge
429    if let Some(ref raw) = input._connection {
430        let config = extract_connection_config(raw)?;
431
432        // Prepend url_prefix if URL is relative (doesn't start with http)
433        if !url.starts_with("http://") && !url.starts_with("https://") {
434            url = format!("{}{}", config.url_prefix, url);
435        }
436
437        // Merge headers (input headers override connection headers)
438        for (k, v) in config.headers {
439            headers.entry(k).or_insert(v);
440        }
441
442        // Merge query parameters (input params override connection params)
443        for (k, v) in config.query_parameters {
444            query_parameters.entry(k).or_insert(v);
445        }
446
447        // TODO: Apply rate limiting using config.rate_limit_config
448    }
449
450    // Build URL with query parameters
451    if !query_parameters.is_empty() {
452        let query_string: String = query_parameters
453            .iter()
454            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
455            .collect::<Vec<_>>()
456            .join("&");
457
458        if url.contains('?') {
459            url = format!("{}&{}", url, query_string);
460        } else {
461            url = format!("{}?{}", url, query_string);
462        }
463    }
464
465    // Create reqwest client with timeout
466    let client = reqwest::Client::builder()
467        .timeout(std::time::Duration::from_millis(input.timeout_ms))
468        .build()
469        .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
470
471    // Build request based on method
472    let method = match input.method {
473        HttpMethod::Get => reqwest::Method::GET,
474        HttpMethod::Post => reqwest::Method::POST,
475        HttpMethod::Put => reqwest::Method::PUT,
476        HttpMethod::Delete => reqwest::Method::DELETE,
477        HttpMethod::Patch => reqwest::Method::PATCH,
478        HttpMethod::Head => reqwest::Method::HEAD,
479        HttpMethod::Options => reqwest::Method::OPTIONS,
480    };
481
482    let mut request = client.request(method, &url);
483
484    // Add headers
485    for (key, value) in &headers {
486        request = request.header(key, value);
487    }
488
489    // Add body if applicable
490    request = match input.method {
491        HttpMethod::Get | HttpMethod::Head | HttpMethod::Options | HttpMethod::Delete => request,
492        HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch => {
493            if let Some(body_str) = input.body.to_string_body() {
494                // Set content-type if not already set
495                if !headers.contains_key("Content-Type") && !headers.contains_key("content-type") {
496                    request = request.header("Content-Type", "application/json");
497                }
498                request.body(body_str)
499            } else {
500                request
501            }
502        }
503    };
504
505    // Execute request
506    let response = request
507        .send()
508        .await
509        .map_err(|e| format!("HTTP request to {} failed: {}", input.url, e))?;
510
511    let status_code = response.status().as_u16();
512    let success = response.status().is_success();
513
514    // Extract headers
515    let mut response_headers = HashMap::new();
516    for (name, value) in response.headers() {
517        if let Ok(v) = value.to_str() {
518            response_headers.insert(name.to_string(), v.to_string());
519        }
520    }
521
522    // Check for error status before consuming body
523    if !success && input.fail_on_error {
524        let body_text = response.text().await.unwrap_or_else(|_| String::new());
525        return Err(format!(
526            "HTTP request failed with status {}: {}",
527            status_code, body_text
528        ));
529    }
530
531    // Read body based on response type
532    let body = match input.response_type {
533        ResponseType::Json => {
534            let text = response
535                .text()
536                .await
537                .map_err(|e| format!("Failed to read response body: {}", e))?;
538            match serde_json::from_str(&text) {
539                Ok(json_value) => HttpResponseBody::Json(json_value),
540                Err(_) => HttpResponseBody::Text(text),
541            }
542        }
543        ResponseType::Text => {
544            let text = response
545                .text()
546                .await
547                .map_err(|e| format!("Failed to read response body: {}", e))?;
548            HttpResponseBody::Text(text)
549        }
550        ResponseType::Binary => {
551            let bytes = response
552                .bytes()
553                .await
554                .map_err(|e| format!("Failed to read response body: {}", e))?;
555            HttpResponseBody::Binary(bytes.to_vec())
556        }
557    };
558
559    Ok(HttpResponse {
560        status_code,
561        headers: response_headers,
562        body,
563        success,
564    })
565}
566
567/// URL encoding helper module
568mod urlencoding {
569    pub fn encode(s: &str) -> String {
570        let mut result = String::new();
571        for c in s.chars() {
572            match c {
573                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
574                _ => {
575                    for byte in c.to_string().as_bytes() {
576                        result.push_str(&format!("%{:02X}", byte));
577                    }
578                }
579            }
580        }
581        result
582    }
583}
584
585mod base64_string {
586    use base64::{Engine as _, engine::general_purpose};
587    use serde::{Deserialize, Deserializer, Serializer};
588
589    pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
590    where
591        S: Serializer,
592    {
593        let encoded = general_purpose::STANDARD.encode(bytes);
594        serializer.serialize_str(&encoded)
595    }
596
597    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
598    where
599        D: Deserializer<'de>,
600    {
601        let encoded = String::deserialize(deserializer)?;
602        general_purpose::STANDARD
603            .decode(encoded.as_bytes())
604            .map_err(serde::de::Error::custom)
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use wiremock::matchers::{body_string, header, method, path, query_param};
612    use wiremock::{Mock, MockServer, ResponseTemplate};
613
614    #[tokio::test]
615    async fn test_get_request_json_response() {
616        let mock_server = MockServer::start().await;
617
618        Mock::given(method("GET"))
619            .and(path("/users"))
620            .respond_with(
621                ResponseTemplate::new(200)
622                    .set_body_json(serde_json::json!({"id": 1, "name": "John"})),
623            )
624            .mount(&mock_server)
625            .await;
626
627        let input = HttpRequestInput {
628            method: HttpMethod::Get,
629            url: format!("{}/users", mock_server.uri()),
630            response_type: ResponseType::Json,
631            ..Default::default()
632        };
633
634        let result = http_request(input).await;
635        assert!(result.is_ok());
636
637        let response = result.unwrap();
638        assert_eq!(response.status_code, 200);
639        assert!(response.success);
640        assert!(matches!(response.body, HttpResponseBody::Json(_)));
641
642        if let HttpResponseBody::Json(json) = response.body {
643            assert_eq!(json["id"], 1);
644            assert_eq!(json["name"], "John");
645        }
646    }
647
648    #[tokio::test]
649    async fn test_post_request_with_json_body() {
650        let mock_server = MockServer::start().await;
651
652        Mock::given(method("POST"))
653            .and(path("/users"))
654            .and(header("Content-Type", "application/json"))
655            .and(body_string(r#"{"name":"Jane"}"#))
656            .respond_with(
657                ResponseTemplate::new(201)
658                    .set_body_json(serde_json::json!({"id": 2, "name": "Jane"})),
659            )
660            .mount(&mock_server)
661            .await;
662
663        let input = HttpRequestInput {
664            method: HttpMethod::Post,
665            url: format!("{}/users", mock_server.uri()),
666            body: HttpBody(serde_json::json!({"name": "Jane"})),
667            response_type: ResponseType::Json,
668            ..Default::default()
669        };
670
671        let result = http_request(input).await;
672        assert!(result.is_ok());
673
674        let response = result.unwrap();
675        assert_eq!(response.status_code, 201);
676        assert!(response.success);
677    }
678
679    #[tokio::test]
680    async fn test_get_request_with_query_parameters() {
681        let mock_server = MockServer::start().await;
682
683        Mock::given(method("GET"))
684            .and(path("/search"))
685            .and(query_param("q", "rust"))
686            .and(query_param("page", "1"))
687            .respond_with(
688                ResponseTemplate::new(200).set_body_json(serde_json::json!({"results": []})),
689            )
690            .mount(&mock_server)
691            .await;
692
693        let mut query_params = HashMap::new();
694        query_params.insert("q".to_string(), "rust".to_string());
695        query_params.insert("page".to_string(), "1".to_string());
696
697        let input = HttpRequestInput {
698            method: HttpMethod::Get,
699            url: format!("{}/search", mock_server.uri()),
700            query_parameters: query_params,
701            response_type: ResponseType::Json,
702            ..Default::default()
703        };
704
705        let result = http_request(input).await;
706        assert!(result.is_ok());
707        assert_eq!(result.unwrap().status_code, 200);
708    }
709
710    #[tokio::test]
711    async fn test_get_request_with_custom_headers() {
712        let mock_server = MockServer::start().await;
713
714        Mock::given(method("GET"))
715            .and(path("/protected"))
716            .and(header("Authorization", "Bearer token123"))
717            .and(header("X-Custom-Header", "custom-value"))
718            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
719            .mount(&mock_server)
720            .await;
721
722        let mut headers = HashMap::new();
723        headers.insert("Authorization".to_string(), "Bearer token123".to_string());
724        headers.insert("X-Custom-Header".to_string(), "custom-value".to_string());
725
726        let input = HttpRequestInput {
727            method: HttpMethod::Get,
728            url: format!("{}/protected", mock_server.uri()),
729            headers,
730            response_type: ResponseType::Json,
731            ..Default::default()
732        };
733
734        let result = http_request(input).await;
735        assert!(result.is_ok());
736        assert_eq!(result.unwrap().status_code, 200);
737    }
738
739    #[tokio::test]
740    async fn test_text_response_type() {
741        let mock_server = MockServer::start().await;
742
743        Mock::given(method("GET"))
744            .and(path("/text"))
745            .respond_with(ResponseTemplate::new(200).set_body_string("Hello, World!"))
746            .mount(&mock_server)
747            .await;
748
749        let input = HttpRequestInput {
750            method: HttpMethod::Get,
751            url: format!("{}/text", mock_server.uri()),
752            response_type: ResponseType::Text,
753            ..Default::default()
754        };
755
756        let result = http_request(input).await;
757        assert!(result.is_ok());
758
759        let response = result.unwrap();
760        assert!(matches!(response.body, HttpResponseBody::Text(_)));
761
762        if let HttpResponseBody::Text(text) = response.body {
763            assert_eq!(text, "Hello, World!");
764        }
765    }
766
767    #[tokio::test]
768    async fn test_binary_response_type() {
769        let mock_server = MockServer::start().await;
770
771        let binary_data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG header bytes
772
773        Mock::given(method("GET"))
774            .and(path("/image"))
775            .respond_with(ResponseTemplate::new(200).set_body_bytes(binary_data.clone()))
776            .mount(&mock_server)
777            .await;
778
779        let input = HttpRequestInput {
780            method: HttpMethod::Get,
781            url: format!("{}/image", mock_server.uri()),
782            response_type: ResponseType::Binary,
783            ..Default::default()
784        };
785
786        let result = http_request(input).await;
787        assert!(result.is_ok());
788
789        let response = result.unwrap();
790        assert!(matches!(response.body, HttpResponseBody::Binary(_)));
791
792        if let HttpResponseBody::Binary(bytes) = response.body {
793            assert_eq!(bytes, binary_data);
794        }
795    }
796
797    #[tokio::test]
798    async fn test_put_request() {
799        let mock_server = MockServer::start().await;
800
801        Mock::given(method("PUT"))
802            .and(path("/users/1"))
803            .respond_with(
804                ResponseTemplate::new(200).set_body_json(serde_json::json!({"updated": true})),
805            )
806            .mount(&mock_server)
807            .await;
808
809        let input = HttpRequestInput {
810            method: HttpMethod::Put,
811            url: format!("{}/users/1", mock_server.uri()),
812            body: HttpBody(serde_json::json!({"name": "Updated"})),
813            ..Default::default()
814        };
815
816        let result = http_request(input).await;
817        assert!(result.is_ok());
818        assert_eq!(result.unwrap().status_code, 200);
819    }
820
821    #[tokio::test]
822    async fn test_delete_request() {
823        let mock_server = MockServer::start().await;
824
825        Mock::given(method("DELETE"))
826            .and(path("/users/1"))
827            .respond_with(ResponseTemplate::new(204))
828            .mount(&mock_server)
829            .await;
830
831        let input = HttpRequestInput {
832            method: HttpMethod::Delete,
833            url: format!("{}/users/1", mock_server.uri()),
834            ..Default::default()
835        };
836
837        let result = http_request(input).await;
838        assert!(result.is_ok());
839        assert_eq!(result.unwrap().status_code, 204);
840    }
841
842    #[tokio::test]
843    async fn test_patch_request() {
844        let mock_server = MockServer::start().await;
845
846        Mock::given(method("PATCH"))
847            .and(path("/users/1"))
848            .respond_with(
849                ResponseTemplate::new(200).set_body_json(serde_json::json!({"patched": true})),
850            )
851            .mount(&mock_server)
852            .await;
853
854        let input = HttpRequestInput {
855            method: HttpMethod::Patch,
856            url: format!("{}/users/1", mock_server.uri()),
857            body: HttpBody(serde_json::json!({"status": "active"})),
858            ..Default::default()
859        };
860
861        let result = http_request(input).await;
862        assert!(result.is_ok());
863        assert_eq!(result.unwrap().status_code, 200);
864    }
865
866    #[tokio::test]
867    async fn test_error_response_with_fail_on_error_true() {
868        let mock_server = MockServer::start().await;
869
870        Mock::given(method("GET"))
871            .and(path("/not-found"))
872            .respond_with(
873                ResponseTemplate::new(404).set_body_json(serde_json::json!({"error": "Not found"})),
874            )
875            .mount(&mock_server)
876            .await;
877
878        let input = HttpRequestInput {
879            method: HttpMethod::Get,
880            url: format!("{}/not-found", mock_server.uri()),
881            fail_on_error: true,
882            ..Default::default()
883        };
884
885        let result = http_request(input).await;
886        assert!(result.is_err());
887        assert!(result.unwrap_err().contains("404"));
888    }
889
890    #[tokio::test]
891    async fn test_error_response_with_fail_on_error_false() {
892        let mock_server = MockServer::start().await;
893
894        Mock::given(method("GET"))
895            .and(path("/not-found"))
896            .respond_with(
897                ResponseTemplate::new(404).set_body_json(serde_json::json!({"error": "Not found"})),
898            )
899            .mount(&mock_server)
900            .await;
901
902        let input = HttpRequestInput {
903            method: HttpMethod::Get,
904            url: format!("{}/not-found", mock_server.uri()),
905            fail_on_error: false,
906            ..Default::default()
907        };
908
909        let result = http_request(input).await;
910        assert!(result.is_ok());
911
912        let response = result.unwrap();
913        assert_eq!(response.status_code, 404);
914        assert!(!response.success);
915    }
916
917    #[tokio::test]
918    async fn test_server_error_with_fail_on_error_false() {
919        let mock_server = MockServer::start().await;
920
921        Mock::given(method("GET"))
922            .and(path("/error"))
923            .respond_with(
924                ResponseTemplate::new(500)
925                    .set_body_json(serde_json::json!({"error": "Internal server error"})),
926            )
927            .mount(&mock_server)
928            .await;
929
930        let input = HttpRequestInput {
931            method: HttpMethod::Get,
932            url: format!("{}/error", mock_server.uri()),
933            fail_on_error: false,
934            ..Default::default()
935        };
936
937        let result = http_request(input).await;
938        assert!(result.is_ok());
939
940        let response = result.unwrap();
941        assert_eq!(response.status_code, 500);
942        assert!(!response.success);
943    }
944
945    #[tokio::test]
946    async fn test_response_headers_captured() {
947        let mock_server = MockServer::start().await;
948
949        Mock::given(method("GET"))
950            .and(path("/headers"))
951            .respond_with(
952                ResponseTemplate::new(200)
953                    .insert_header("X-Custom-Response", "custom-value")
954                    .insert_header("X-Request-Id", "12345")
955                    .set_body_json(serde_json::json!({})),
956            )
957            .mount(&mock_server)
958            .await;
959
960        let input = HttpRequestInput {
961            method: HttpMethod::Get,
962            url: format!("{}/headers", mock_server.uri()),
963            ..Default::default()
964        };
965
966        let result = http_request(input).await;
967        assert!(result.is_ok());
968
969        let response = result.unwrap();
970        assert_eq!(
971            response.headers.get("x-custom-response"),
972            Some(&"custom-value".to_string())
973        );
974        assert_eq!(
975            response.headers.get("x-request-id"),
976            Some(&"12345".to_string())
977        );
978    }
979
980    #[tokio::test]
981    async fn test_head_request() {
982        let mock_server = MockServer::start().await;
983
984        Mock::given(method("HEAD"))
985            .and(path("/resource"))
986            .respond_with(ResponseTemplate::new(200).insert_header("Content-Length", "1024"))
987            .mount(&mock_server)
988            .await;
989
990        let input = HttpRequestInput {
991            method: HttpMethod::Head,
992            url: format!("{}/resource", mock_server.uri()),
993            ..Default::default()
994        };
995
996        let result = http_request(input).await;
997        assert!(result.is_ok());
998
999        let response = result.unwrap();
1000        assert_eq!(response.status_code, 200);
1001    }
1002
1003    #[tokio::test]
1004    async fn test_options_request() {
1005        let mock_server = MockServer::start().await;
1006
1007        Mock::given(method("OPTIONS"))
1008            .and(path("/api"))
1009            .respond_with(
1010                ResponseTemplate::new(200).insert_header("Allow", "GET, POST, PUT, DELETE"),
1011            )
1012            .mount(&mock_server)
1013            .await;
1014
1015        let input = HttpRequestInput {
1016            method: HttpMethod::Options,
1017            url: format!("{}/api", mock_server.uri()),
1018            ..Default::default()
1019        };
1020
1021        let result = http_request(input).await;
1022        assert!(result.is_ok());
1023
1024        let response = result.unwrap();
1025        assert_eq!(response.status_code, 200);
1026        assert!(response.headers.get("allow").is_some());
1027    }
1028
1029    #[tokio::test]
1030    async fn test_json_response_fallback_to_text() {
1031        let mock_server = MockServer::start().await;
1032
1033        // Return invalid JSON when JSON response is expected
1034        Mock::given(method("GET"))
1035            .and(path("/invalid-json"))
1036            .respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
1037            .mount(&mock_server)
1038            .await;
1039
1040        let input = HttpRequestInput {
1041            method: HttpMethod::Get,
1042            url: format!("{}/invalid-json", mock_server.uri()),
1043            response_type: ResponseType::Json,
1044            ..Default::default()
1045        };
1046
1047        let result = http_request(input).await;
1048        assert!(result.is_ok());
1049
1050        let response = result.unwrap();
1051        // Should fall back to text when JSON parsing fails
1052        assert!(matches!(response.body, HttpResponseBody::Text(_)));
1053
1054        if let HttpResponseBody::Text(text) = response.body {
1055            assert_eq!(text, "not valid json");
1056        }
1057    }
1058}