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 std::io::Read;
20use strum::VariantNames;
21
22// ============================================================================
23// Enums
24// ============================================================================
25
26/// HTTP method for the request
27#[derive(Debug, Clone, Serialize, Deserialize, VariantNames)]
28#[serde(rename_all = "UPPERCASE")]
29#[strum(serialize_all = "UPPERCASE")]
30pub enum HttpMethod {
31    /// GET request - retrieve data
32    Get,
33    /// POST request - create or submit data
34    Post,
35    /// PUT request - update or replace data
36    Put,
37    /// DELETE request - remove data
38    Delete,
39    /// PATCH request - partially update data
40    Patch,
41    /// HEAD request - retrieve headers only
42    Head,
43    /// OPTIONS request - query supported methods
44    Options,
45}
46
47impl EnumVariants for HttpMethod {
48    fn variant_names() -> &'static [&'static str] {
49        Self::VARIANTS
50    }
51}
52
53impl Default for HttpMethod {
54    fn default() -> Self {
55        Self::Get
56    }
57}
58
59impl HttpMethod {
60    pub fn as_str(&self) -> &str {
61        match self {
62            Self::Get => "GET",
63            Self::Post => "POST",
64            Self::Put => "PUT",
65            Self::Delete => "DELETE",
66            Self::Patch => "PATCH",
67            Self::Head => "HEAD",
68            Self::Options => "OPTIONS",
69        }
70    }
71}
72
73/// Expected format of the HTTP response body
74#[derive(Debug, Clone, Serialize, Deserialize, VariantNames)]
75#[serde(rename_all = "lowercase")]
76#[strum(serialize_all = "lowercase")]
77pub enum ResponseType {
78    /// Parse response as JSON
79    Json,
80    /// Return response as plain text
81    Text,
82    /// Return response as raw binary data
83    Binary,
84}
85
86impl EnumVariants for ResponseType {
87    fn variant_names() -> &'static [&'static str] {
88        Self::VARIANTS
89    }
90}
91
92impl Default for ResponseType {
93    fn default() -> Self {
94        Self::Json
95    }
96}
97
98impl ResponseType {
99    pub fn as_str(&self) -> &str {
100        match self {
101            Self::Json => "json",
102            Self::Text => "text",
103            Self::Binary => "binary",
104        }
105    }
106}
107
108// ============================================================================
109// Input/Output Types
110// ============================================================================
111
112/// Represents the body of an HTTP request
113///
114/// Note: This is now just a Value wrapper to handle all input types uniformly.
115/// The actual conversion to JSON/text/binary happens when sending the request.
116#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117#[serde(transparent)]
118pub struct HttpBody(pub Value);
119
120impl HttpBody {
121    /// Check if body is empty/null
122    pub fn is_empty(&self) -> bool {
123        self.0.is_null()
124    }
125
126    /// Convert to string for sending in request
127    pub fn to_string_body(&self) -> Option<String> {
128        match &self.0 {
129            Value::Null => None,
130            Value::String(s) if s.is_empty() => None,
131            Value::String(s) => Some(s.clone()),
132            other => Some(other.to_string()),
133        }
134    }
135}
136
137/// Represents the body of an HTTP response
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(untagged)]
140pub enum HttpResponseBody {
141    /// Binary response body (base64 encoded)
142    #[serde(with = "base64_string")]
143    Binary(Vec<u8>),
144    /// Text response body
145    Text(String),
146    /// JSON response body
147    Json(Value),
148}
149
150/// Body type for HTTP requests
151#[derive(Debug, Clone, Serialize, Deserialize, VariantNames)]
152#[serde(rename_all = "lowercase")]
153#[strum(serialize_all = "lowercase")]
154pub enum BodyType {
155    /// JSON body (default)
156    Json,
157    /// Plain text body
158    Text,
159    /// Raw binary body (base64 encoded in input)
160    Binary,
161    /// Multipart form data (for file uploads)
162    Multipart,
163}
164
165impl EnumVariants for BodyType {
166    fn variant_names() -> &'static [&'static str] {
167        Self::VARIANTS
168    }
169}
170
171impl Default for BodyType {
172    fn default() -> Self {
173        Self::Json
174    }
175}
176
177/// A part of a multipart form request
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct MultipartPart {
180    /// Field name
181    pub name: String,
182
183    /// Field value (string) or file data
184    #[serde(flatten)]
185    pub content: MultipartContent,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(untagged)]
190pub enum MultipartContent {
191    /// Text field
192    Text { value: String },
193
194    /// File field (base64 encoded)
195    File {
196        /// Base64 encoded file content
197        content: String,
198        /// Filename for Content-Disposition header
199        #[serde(skip_serializing_if = "Option::is_none")]
200        filename: Option<String>,
201        /// Content-Type for this part
202        #[serde(skip_serializing_if = "Option::is_none")]
203        #[serde(rename = "contentType")]
204        content_type: Option<String>,
205    },
206}
207
208/// Input structure for HTTP request operation
209#[derive(Debug, Serialize, Deserialize, CapabilityInput)]
210#[capability_input(display_name = "HTTP Request Input")]
211pub struct HttpRequestInput {
212    /// HTTP method
213    #[field(
214        display_name = "Method",
215        description = "HTTP verb for the request",
216        example = "GET",
217        default = "GET",
218        enum_type = "HttpMethod"
219    )]
220    #[serde(default)]
221    pub method: HttpMethod,
222
223    /// Target URL
224    #[field(
225        display_name = "URL",
226        description = "Full URL to send the request to",
227        example = "https://api.example.com/v1/users"
228    )]
229    pub url: String,
230
231    /// Optional connection ID for automatic authentication
232    #[field(
233        display_name = "Connection ID",
234        description = "Connection ID for automatic authentication and URL prefixing"
235    )]
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub connection_id: Option<String>,
238
239    /// HTTP headers
240    #[field(
241        display_name = "Headers",
242        description = "Custom HTTP headers",
243        example = r#"{"Authorization": "Bearer token123"}"#,
244        default = "{}"
245    )]
246    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
247    pub headers: HashMap<String, String>,
248
249    /// Query parameters
250    #[field(
251        display_name = "Query Parameters",
252        description = "URL query parameters",
253        example = r#"{"page": "1", "limit": "100"}"#,
254        default = "{}"
255    )]
256    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
257    pub query_parameters: HashMap<String, String>,
258
259    /// Request body
260    #[field(
261        display_name = "Body",
262        description = "Request payload",
263        example = r#"{"name": "John Doe", "email": "john@example.com"}"#,
264        default = "null"
265    )]
266    #[serde(default, skip_serializing_if = "HttpBody::is_empty")]
267    pub body: HttpBody,
268
269    /// Body type for the request
270    #[field(
271        display_name = "Body Type",
272        description = "How to encode the request body",
273        example = "json",
274        default = "json",
275        enum_type = "BodyType"
276    )]
277    #[serde(default)]
278    pub body_type: BodyType,
279
280    /// Multipart form parts (used when body_type is "multipart")
281    #[field(
282        display_name = "Multipart Parts",
283        description = "Form fields and files to include in multipart requests",
284        default = "[]"
285    )]
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub multipart: Vec<MultipartPart>,
288
289    /// Response body type
290    #[field(
291        display_name = "Response Type",
292        description = "Expected response format",
293        example = "json",
294        default = "json",
295        enum_type = "ResponseType"
296    )]
297    #[serde(default)]
298    pub response_type: ResponseType,
299
300    /// Request timeout in milliseconds
301    #[field(
302        display_name = "Timeout (ms)",
303        description = "Maximum time to wait for response",
304        example = "5000",
305        default = "30000"
306    )]
307    #[serde(default = "default_timeout")]
308    pub timeout_ms: u64,
309
310    /// Whether to fail the step on non-2xx responses
311    #[field(
312        display_name = "Fail on Error",
313        description = "If true (default), non-2xx responses will fail the step. If false, non-2xx responses are returned normally.",
314        example = "true",
315        default = "true"
316    )]
317    #[serde(default = "default_fail_on_error")]
318    pub fail_on_error: bool,
319}
320
321impl Default for HttpRequestInput {
322    fn default() -> Self {
323        HttpRequestInput {
324            method: HttpMethod::default(),
325            url: String::new(),
326            connection_id: None,
327            headers: HashMap::new(),
328            query_parameters: HashMap::new(),
329            body: HttpBody(Value::Null),
330            response_type: ResponseType::default(),
331            timeout_ms: default_timeout(),
332            body_type: BodyType::default(),
333            multipart: Vec::new(),
334            fail_on_error: default_fail_on_error(),
335        }
336    }
337}
338
339fn default_timeout() -> u64 {
340    30000
341}
342
343fn default_fail_on_error() -> bool {
344    true
345}
346
347/// HTTP response metadata (without body)
348#[derive(Debug, Serialize, Deserialize)]
349#[allow(dead_code)]
350struct HttpResponseMetadata {
351    /// HTTP status code (e.g., 200, 404, 500)
352    pub status_code: u16,
353
354    /// Response headers
355    pub headers: HashMap<String, String>,
356
357    /// Length of the response body in bytes
358    pub body_length: usize,
359
360    /// Response type: "json", "text", or "binary"
361    pub response_type: String,
362
363    /// Whether the request was successful (2xx status code)
364    pub success: bool,
365}
366
367/// HTTP response structure
368#[derive(Debug, Serialize, Deserialize, CapabilityOutput)]
369#[capability_output(
370    display_name = "HTTP Response",
371    description = "Response from an HTTP request"
372)]
373pub struct HttpResponse {
374    #[field(
375        display_name = "Status Code",
376        description = "HTTP status code (e.g., 200, 404, 500)",
377        example = "200"
378    )]
379    pub status_code: u16,
380
381    #[field(
382        display_name = "Headers",
383        description = "Response headers as key-value pairs"
384    )]
385    pub headers: HashMap<String, String>,
386
387    #[field(
388        display_name = "Body",
389        description = "Response body (JSON object, text string, or base64-encoded binary depending on response_type)"
390    )]
391    pub body: HttpResponseBody,
392
393    #[field(
394        display_name = "Success",
395        description = "True if the status code is in the 2xx range",
396        example = "true"
397    )]
398    pub success: bool,
399}
400
401// Re-export HttpConnectionConfig from extractors for convenience
402pub use crate::extractors::HttpConnectionConfig;
403
404/// Extract HTTP connection config from a raw connection using registered extractors
405pub fn extract_connection_config(
406    raw: &crate::connections::RawConnection,
407) -> Result<HttpConnectionConfig, String> {
408    crate::extractors::extract_http_config(
409        &raw.integration_id,
410        &raw.parameters,
411        raw.rate_limit_config.clone(),
412    )
413}
414
415// ============================================================================
416// Operations
417// ============================================================================
418
419/// Execute an HTTP request using native ureq
420#[capability(
421    module = "http",
422    display_name = "HTTP Request",
423    description = "Execute an HTTP request with the specified method, URL, headers, and body",
424    side_effects = true
425)]
426pub fn http_request(input: HttpRequestInput) -> Result<HttpResponse, String> {
427    // Start with input values
428    let mut headers = input.headers.clone();
429    let mut query_parameters = input.query_parameters.clone();
430    let mut url = input.url.clone();
431
432    // If connection_id provided, extract config and merge
433    if let Some(ref conn_id) = input.connection_id {
434        let raw = crate::connections::resolve_connection(conn_id)?;
435        let config = extract_connection_config(&raw)?;
436
437        // Prepend url_prefix if URL is relative (doesn't start with http)
438        if !url.starts_with("http://") && !url.starts_with("https://") {
439            url = format!("{}{}", config.url_prefix, url);
440        }
441
442        // Merge headers (input headers override connection headers)
443        for (k, v) in config.headers {
444            headers.entry(k).or_insert(v);
445        }
446
447        // Merge query parameters (input params override connection params)
448        for (k, v) in config.query_parameters {
449            query_parameters.entry(k).or_insert(v);
450        }
451
452        // TODO: Apply rate limiting using config.rate_limit_config
453    }
454
455    // Build URL with query parameters
456    if !query_parameters.is_empty() {
457        let query_string: String = query_parameters
458            .iter()
459            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
460            .collect::<Vec<_>>()
461            .join("&");
462
463        if url.contains('?') {
464            url = format!("{}&{}", url, query_string);
465        } else {
466            url = format!("{}?{}", url, query_string);
467        }
468    }
469
470    // Create ureq agent with timeout
471    let agent = ureq::AgentBuilder::new()
472        .timeout(std::time::Duration::from_millis(input.timeout_ms))
473        .build();
474
475    // Build request based on method
476    let request = match input.method {
477        HttpMethod::Get => agent.get(&url),
478        HttpMethod::Post => agent.post(&url),
479        HttpMethod::Put => agent.put(&url),
480        HttpMethod::Delete => agent.delete(&url),
481        HttpMethod::Patch => agent.patch(&url),
482        HttpMethod::Head => agent.head(&url),
483        HttpMethod::Options => agent.request("OPTIONS", &url),
484    };
485
486    // Add headers
487    let mut request = request;
488    for (key, value) in &headers {
489        request = request.set(key, value);
490    }
491
492    // Execute request with body if applicable
493    let response = match input.method {
494        HttpMethod::Get | HttpMethod::Head | HttpMethod::Options | HttpMethod::Delete => {
495            request.call()
496        }
497        HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch => {
498            if let Some(body_str) = input.body.to_string_body() {
499                // Set content-type if not already set
500                if !headers.contains_key("Content-Type") && !headers.contains_key("content-type") {
501                    request = request.set("Content-Type", "application/json");
502                }
503                request.send_string(&body_str)
504            } else {
505                request.call()
506            }
507        }
508    };
509
510    // Handle response
511    match response {
512        Ok(resp) => {
513            let status_code = resp.status();
514
515            // Extract headers
516            let mut headers = HashMap::new();
517            for name in resp.headers_names() {
518                if let Some(value) = resp.header(&name) {
519                    headers.insert(name, value.to_string());
520                }
521            }
522
523            // Read body
524            let body = match input.response_type {
525                ResponseType::Json => match resp.into_string() {
526                    Ok(text) => match serde_json::from_str(&text) {
527                        Ok(json_value) => HttpResponseBody::Json(json_value),
528                        Err(_) => HttpResponseBody::Text(text),
529                    },
530                    Err(e) => return Err(format!("Failed to read response body: {}", e)),
531                },
532                ResponseType::Text => match resp.into_string() {
533                    Ok(text) => HttpResponseBody::Text(text),
534                    Err(e) => return Err(format!("Failed to read response body: {}", e)),
535                },
536                ResponseType::Binary => {
537                    let mut bytes = Vec::new();
538                    match resp.into_reader().read_to_end(&mut bytes) {
539                        Ok(_) => HttpResponseBody::Binary(bytes),
540                        Err(e) => return Err(format!("Failed to read response body: {}", e)),
541                    }
542                }
543            };
544
545            let success = status_code >= 200 && status_code < 300;
546
547            Ok(HttpResponse {
548                status_code,
549                headers,
550                body,
551                success,
552            })
553        }
554        Err(ureq::Error::Status(status_code, resp)) => {
555            // HTTP error response (4xx, 5xx)
556            let mut headers = HashMap::new();
557            for name in resp.headers_names() {
558                if let Some(value) = resp.header(&name) {
559                    headers.insert(name, value.to_string());
560                }
561            }
562
563            let body_text = resp.into_string().unwrap_or_default();
564            let body = match serde_json::from_str(&body_text) {
565                Ok(json_value) => HttpResponseBody::Json(json_value),
566                Err(_) => HttpResponseBody::Text(body_text.clone()),
567            };
568
569            // If fail_on_error is true, return an error instead of a response
570            if input.fail_on_error {
571                return Err(format!(
572                    "HTTP request failed with status {}: {}",
573                    status_code, body_text
574                ));
575            }
576
577            Ok(HttpResponse {
578                status_code,
579                headers,
580                body,
581                success: false,
582            })
583        }
584        Err(ureq::Error::Transport(transport)) => Err(format!(
585            "HTTP request to {} failed: {}",
586            input.url, transport
587        )),
588    }
589}
590
591/// URL encoding helper module
592mod urlencoding {
593    pub fn encode(s: &str) -> String {
594        let mut result = String::new();
595        for c in s.chars() {
596            match c {
597                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
598                _ => {
599                    for byte in c.to_string().as_bytes() {
600                        result.push_str(&format!("%{:02X}", byte));
601                    }
602                }
603            }
604        }
605        result
606    }
607}
608
609mod base64_string {
610    use base64::{Engine as _, engine::general_purpose};
611    use serde::{Deserialize, Deserializer, Serializer};
612
613    pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
614    where
615        S: Serializer,
616    {
617        let encoded = general_purpose::STANDARD.encode(bytes);
618        serializer.serialize_str(&encoded)
619    }
620
621    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
622    where
623        D: Deserializer<'de>,
624    {
625        let encoded = String::deserialize(deserializer)?;
626        general_purpose::STANDARD
627            .decode(encoded.as_bytes())
628            .map_err(serde::de::Error::custom)
629    }
630}