Skip to main content

cersei_tools/tool_primitives/
http.rs

1//! HTTP client primitives.
2//!
3//! GET, POST, and HTML-to-text fetching built on reqwest.
4
5use std::collections::HashMap;
6use std::time::Duration;
7
8/// HTTP response.
9#[derive(Debug, Clone)]
10pub struct HttpResponse {
11    pub status: u16,
12    pub headers: HashMap<String, String>,
13    pub body: String,
14    pub content_type: Option<String>,
15}
16
17/// Options for HTTP requests.
18#[derive(Debug, Clone)]
19pub struct HttpOptions {
20    pub headers: HashMap<String, String>,
21    pub timeout: Option<Duration>,
22    pub user_agent: Option<String>,
23}
24
25impl Default for HttpOptions {
26    fn default() -> Self {
27        Self {
28            headers: HashMap::new(),
29            timeout: Some(Duration::from_secs(30)),
30            user_agent: Some("Cersei-Agent/0.1".into()),
31        }
32    }
33}
34
35/// HTTP errors.
36#[derive(Debug)]
37pub enum HttpError {
38    RequestFailed(String),
39    Timeout,
40    ClientBuild(String),
41}
42
43impl std::fmt::Display for HttpError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::RequestFailed(msg) => write!(f, "HTTP request failed: {msg}"),
47            Self::Timeout => write!(f, "HTTP request timed out"),
48            Self::ClientBuild(msg) => write!(f, "failed to build HTTP client: {msg}"),
49        }
50    }
51}
52
53impl std::error::Error for HttpError {}
54
55fn build_client(opts: &HttpOptions) -> Result<reqwest::Client, HttpError> {
56    let mut builder = reqwest::Client::builder();
57
58    if let Some(timeout) = opts.timeout {
59        builder = builder.timeout(timeout);
60    }
61
62    if let Some(ua) = &opts.user_agent {
63        builder = builder.user_agent(ua);
64    }
65
66    builder
67        .build()
68        .map_err(|e| HttpError::ClientBuild(e.to_string()))
69}
70
71/// Send a GET request.
72pub async fn get(url: &str, opts: HttpOptions) -> Result<HttpResponse, HttpError> {
73    let client = build_client(&opts)?;
74    let mut req = client.get(url);
75
76    for (k, v) in &opts.headers {
77        req = req.header(k.as_str(), v.as_str());
78    }
79
80    let resp = req.send().await.map_err(|e| {
81        if e.is_timeout() {
82            HttpError::Timeout
83        } else {
84            HttpError::RequestFailed(e.to_string())
85        }
86    })?;
87
88    let status = resp.status().as_u16();
89    let content_type = resp
90        .headers()
91        .get("content-type")
92        .and_then(|v| v.to_str().ok())
93        .map(String::from);
94    let headers: HashMap<String, String> = resp
95        .headers()
96        .iter()
97        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
98        .collect();
99    let body = resp
100        .text()
101        .await
102        .map_err(|e| HttpError::RequestFailed(e.to_string()))?;
103
104    Ok(HttpResponse {
105        status,
106        headers,
107        body,
108        content_type,
109    })
110}
111
112/// Send a POST request with a string body.
113pub async fn post(url: &str, body: &str, opts: HttpOptions) -> Result<HttpResponse, HttpError> {
114    let client = build_client(&opts)?;
115    let mut req = client.post(url).body(body.to_string());
116
117    for (k, v) in &opts.headers {
118        req = req.header(k.as_str(), v.as_str());
119    }
120
121    let resp = req.send().await.map_err(|e| {
122        if e.is_timeout() {
123            HttpError::Timeout
124        } else {
125            HttpError::RequestFailed(e.to_string())
126        }
127    })?;
128
129    let status = resp.status().as_u16();
130    let content_type = resp
131        .headers()
132        .get("content-type")
133        .and_then(|v| v.to_str().ok())
134        .map(String::from);
135    let headers: HashMap<String, String> = resp
136        .headers()
137        .iter()
138        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
139        .collect();
140    let body = resp
141        .text()
142        .await
143        .map_err(|e| HttpError::RequestFailed(e.to_string()))?;
144
145    Ok(HttpResponse {
146        status,
147        headers,
148        body,
149        content_type,
150    })
151}
152
153/// Fetch a URL and convert HTML to readable plain text.
154/// Non-HTML content is returned as-is. Truncated to `max_chars`.
155pub async fn fetch_html(
156    url: &str,
157    max_chars: usize,
158    opts: HttpOptions,
159) -> Result<String, HttpError> {
160    let resp = get(url, opts).await?;
161
162    let is_html = resp
163        .content_type
164        .as_deref()
165        .map(|ct| ct.contains("html"))
166        .unwrap_or(false);
167
168    let text = if is_html {
169        html2text::from_read(resp.body.as_bytes(), 80)
170    } else {
171        resp.body
172    };
173
174    if text.len() > max_chars {
175        Ok(text[..max_chars].to_string())
176    } else {
177        Ok(text)
178    }
179}