cersei_tools/tool_primitives/
http.rs1use std::collections::HashMap;
6use std::time::Duration;
7
8#[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#[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#[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
71pub 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
112pub 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
153pub 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}