Skip to main content

vynil_core/
http.rs

1//! HTTP client (`RestClient`) and free helpers (`http_get_yaml`, `headers_get`).
2//!
3//! Requires the `http` feature (which implies `rhai`). All requests use the global
4//! client identity from [`crate::set_client_name`] as `User-Agent`.
5
6use crate::{Error, Error::*, RhaiRes, rhai_err};
7use base64::{Engine as _, engine::general_purpose::STANDARD};
8use reqwest::{Certificate, Client, Response};
9use rhai::{Dynamic, Engine, Map};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13use serde_yaml;
14use tokio::runtime::Handle;
15use tracing::*;
16
17#[derive(Serialize, Deserialize, Eq, PartialEq, Clone, Debug, JsonSchema, Default)]
18pub enum ReadMethod {
19    #[default]
20    Get,
21}
22#[derive(Serialize, Deserialize, Eq, PartialEq, Clone, Debug, JsonSchema, Default)]
23pub enum CreateMethod {
24    #[default]
25    Post,
26    Put,
27}
28
29#[derive(Serialize, Deserialize, Eq, PartialEq, Clone, Debug, JsonSchema, Default)]
30pub enum UpdateMethod {
31    #[default]
32    Patch,
33    Put,
34    Post,
35    None,
36}
37
38#[derive(Serialize, Deserialize, Eq, PartialEq, Clone, Debug, JsonSchema, Default)]
39pub enum DeleteMethod {
40    #[default]
41    Delete,
42}
43
44/// Reqwest-based HTTP client with builder-style header / TLS configuration.
45///
46///
47/// ```rust,no_run
48/// # vynil_core::set_client_name(|| "my-app.example.com".into());
49/// let mut c = vynil_core::http::RestClient::new("https://example.com");
50/// c.add_header_bearer("token");
51/// // let resp: serde_json::Value = c.json_get("api/v1/foo").unwrap();
52/// ```
53#[derive(Clone, Debug)]
54pub struct RestClient {
55    baseurl: String,
56    headers: Map,
57    server_ca: Option<String>,
58    client_key: Option<String>,
59    client_cert: Option<String>,
60}
61
62impl RestClient {
63    #[must_use]
64    pub fn new(base: &str) -> Self {
65        Self {
66            baseurl: base.to_string(),
67            headers: Map::new(),
68            server_ca: None,
69            client_cert: None,
70            client_key: None,
71        }
72    }
73
74    pub fn baseurl(&mut self, base: &str) -> &mut RestClient {
75        self.baseurl = base.to_string();
76        self
77    }
78
79    pub fn set_server_ca(&mut self, ca: &str) {
80        self.server_ca = Some(ca.to_string());
81    }
82
83    pub fn set_mtls(&mut self, cert: &str, key: &str) {
84        self.client_cert = Some(cert.to_string());
85        self.client_key = Some(key.to_string());
86    }
87
88    pub fn baseurl_rhai(&mut self, base: String) {
89        self.baseurl(base.as_str());
90    }
91
92    pub fn headers_reset(&mut self) -> &mut RestClient {
93        self.headers = Map::new();
94        self
95    }
96
97    pub fn headers_reset_rhai(&mut self) {
98        self.headers_reset();
99    }
100
101    pub fn add_header(&mut self, key: &str, value: &str) -> &mut RestClient {
102        self.headers
103            .insert(key.to_string().into(), value.to_string().into());
104        self
105    }
106
107    pub fn add_header_rhai(&mut self, key: String, value: String) {
108        self.add_header(key.as_str(), value.as_str());
109    }
110
111    pub fn add_header_json_content(&mut self) -> &mut RestClient {
112        if self
113            .headers
114            .clone()
115            .into_iter()
116            .any(|(c, _)| c == *"Content-Type")
117        {
118            self
119        } else {
120            self.add_header("Content-Type", "application/json; charset=utf-8")
121        }
122    }
123
124    pub fn add_header_json_accept(&mut self) -> &mut RestClient {
125        for (key, val) in self.headers.clone() {
126            debug!("RestClient.header: {:} {:}", key, val);
127        }
128        if self.headers.clone().into_iter().any(|(c, _)| c == *"Accept") {
129            self
130        } else {
131            self.add_header("Accept", "application/json")
132        }
133    }
134
135    pub fn add_header_json(&mut self) {
136        self.add_header_json_content().add_header_json_accept();
137    }
138
139    pub fn add_header_bearer(&mut self, token: &str) {
140        self.add_header("Authorization", format!("Bearer {token}").as_str());
141    }
142
143    pub fn add_header_basic(&mut self, username: &str, password: &str) {
144        let hash = STANDARD.encode(format!("{username}:{password}"));
145        self.add_header("Authorization", format!("Basic {hash}").as_str());
146    }
147
148    fn get_client(&mut self) -> std::result::Result<Client, reqwest::Error> {
149        let five_sec = std::time::Duration::from_secs(60 * 5);
150        if self.server_ca.is_none() && (self.client_cert.is_none() || self.client_key.is_none()) {
151            Client::builder()
152                .user_agent(crate::get_client_name())
153                .timeout(five_sec)
154                .build()
155        } else if self.client_cert.is_none() || self.client_key.is_none() {
156            match Certificate::from_pem(self.server_ca.clone().unwrap().as_bytes()) {
157                Ok(c) => Client::builder()
158                    .user_agent(crate::get_client_name())
159                    .timeout(five_sec)
160                    .add_root_certificate(c)
161                    .use_rustls_tls()
162                    .build(),
163                Err(e) => Err(e),
164            }
165        } else {
166            let cli_cert = format!(
167                "{}\n{}",
168                self.client_key.clone().unwrap(),
169                self.client_cert.clone().unwrap()
170            );
171            match reqwest::Identity::from_pem(cli_cert.as_bytes()) {
172                Ok(identity) => {
173                    if self.server_ca.is_none() {
174                        Client::builder()
175                            .user_agent(crate::get_client_name())
176                            .timeout(five_sec)
177                            .use_rustls_tls()
178                            .identity(identity)
179                            .build()
180                    } else {
181                        match Certificate::from_pem(self.server_ca.clone().unwrap().as_bytes()) {
182                            Ok(c) => Client::builder()
183                                .user_agent(crate::get_client_name())
184                                .timeout(five_sec)
185                                .add_root_certificate(c)
186                                .use_rustls_tls()
187                                .identity(identity)
188                                .build(),
189                            Err(e) => Err(e),
190                        }
191                    }
192                }
193                Err(e) => Err(e),
194            }
195        }
196    }
197
198    pub fn http_get(&mut self, path: &str) -> std::result::Result<Response, reqwest::Error> {
199        debug!("http_get '{}' ", format!("{}/{}", self.baseurl, path));
200        match self.get_client() {
201            Ok(client) => {
202                let mut req = client.get(format!("{}/{}", self.baseurl, path));
203                for (key, val) in self.headers.clone() {
204                    req = req.header(key.to_string(), val.to_string());
205                }
206                tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await }))
207            }
208            Err(e) => {
209                if e.is_builder() {
210                    warn!("CLIENT: {e:?}");
211                }
212                Err(e)
213            }
214        }
215    }
216
217    pub fn body_get(&mut self, path: &str) -> crate::Result<String> {
218        let response = self.http_get(path).map_err(Error::ReqwestError)?;
219        if !response.status().is_success() {
220            let status = response.status();
221            let text = tokio::task::block_in_place(|| {
222                Handle::current().block_on(async move { response.text().await })
223            })
224            .map_err(Error::ReqwestError)?;
225            return Err(Error::MethodFailed(
226                "Get".to_string(),
227                status.as_u16(),
228                format!(
229                    "The server returned the error: {} {} | {text}",
230                    status.as_str(),
231                    status.canonical_reason().unwrap_or("unknown")
232                ),
233            ));
234        }
235        let text =
236            tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await }))
237                .map_err(Error::ReqwestError)?;
238        Ok(text)
239    }
240
241    pub fn json_get(&mut self, path: &str) -> crate::Result<Value> {
242        let text = self.body_get(path)?;
243        let json = serde_json::from_str(&text).map_err(Error::JsonError)?;
244        Ok(json)
245    }
246
247    pub fn rhai_get(&mut self, path: String) -> RhaiRes<Map> {
248        let mut ret = Map::new();
249        match self.http_get(path.as_str()) {
250            Ok(result) => {
251                ret.insert(
252                    "code".to_string().into(),
253                    Dynamic::from_int(result.status().as_u16().to_string().parse::<i64>().unwrap_or(0)),
254                );
255                tokio::task::block_in_place(|| {
256                    tokio::runtime::Handle::current().block_on(async {
257                        let headers = result
258                            .headers()
259                            .into_iter()
260                            .map(|(key, val)| {
261                                (
262                                    key.as_str().to_string(),
263                                    val.to_str().unwrap_or_default().to_string(),
264                                )
265                            })
266                            .collect::<Vec<(String, String)>>();
267                        let text = match result.text().await {
268                            Ok(t) => t,
269                            Err(e) => {
270                                ret.insert(
271                                    "body".to_string().into(),
272                                    Dynamic::from(format!("Error reading response body: {e}")),
273                                );
274                                ret.insert("json".to_string().into(), Dynamic::from(json!({})));
275                                ret.insert("headers".to_string().into(), Dynamic::from(headers));
276                                return Err(format!("Error reading response body: {e}").into());
277                            }
278                        };
279                        ret.insert(
280                            "json".to_string().into(),
281                            serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))),
282                        );
283                        ret.insert("headers".to_string().into(), Dynamic::from(headers.clone()));
284                        ret.insert("body".to_string().into(), Dynamic::from(text));
285                        Ok(ret)
286                    })
287                })
288            }
289            Err(e) => Err(crate::error_chain(&e).into()),
290        }
291    }
292
293    pub fn http_head(&mut self, path: &str) -> std::result::Result<Response, reqwest::Error> {
294        debug!("http_head '{}' ", format!("{}/{}", self.baseurl, path));
295        match self.get_client() {
296            Ok(client) => {
297                let mut req = client.head(format!("{}/{}", self.baseurl, path));
298                for (key, val) in self.headers.clone() {
299                    req = req.header(key.to_string(), val.to_string());
300                }
301                tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await }))
302            }
303            Err(e) => {
304                if e.is_builder() {
305                    warn!("CLIENT: {e:?}");
306                }
307                Err(e)
308            }
309        }
310    }
311
312    pub fn header_head(&mut self, path: &str) -> crate::Result<Vec<(String, String)>> {
313        let response = self.http_get(path).map_err(Error::ReqwestError)?;
314        if !response.status().is_success() {
315            let status = response.status();
316            return Err(Error::MethodFailed(
317                "Get".to_string(),
318                status.as_u16(),
319                format!(
320                    "The server returned the error: {} {}",
321                    status.as_str(),
322                    status.canonical_reason().unwrap_or("unknown")
323                ),
324            ));
325        }
326        Ok(response
327            .headers()
328            .into_iter()
329            .map(|(key, val)| {
330                (
331                    key.as_str().to_string(),
332                    val.to_str().unwrap_or_default().to_string(),
333                )
334            })
335            .collect())
336    }
337
338    pub fn rhai_head(&mut self, path: String) -> RhaiRes<Map> {
339        let mut ret = Map::new();
340        match self.http_head(path.as_str()) {
341            Ok(result) => {
342                ret.insert(
343                    "code".to_string().into(),
344                    Dynamic::from_int(result.status().as_u16().to_string().parse::<i64>().unwrap_or(0)),
345                );
346                let headers = result
347                    .headers()
348                    .into_iter()
349                    .map(|(key, val)| {
350                        (
351                            key.as_str().to_string(),
352                            val.to_str().unwrap_or_default().to_string(),
353                        )
354                    })
355                    .collect::<Vec<(String, String)>>();
356                ret.insert("headers".to_string().into(), Dynamic::from(headers.clone()));
357                Ok(ret)
358            }
359            Err(e) => Err(crate::error_chain(&e).into()),
360        }
361    }
362
363    pub fn http_patch(&mut self, path: &str, body: &str) -> crate::Result<Response> {
364        debug!("http_patch '{}' ", format!("{}/{}", self.baseurl, path));
365        match self.get_client() {
366            Ok(client) => {
367                let mut req = client
368                    .patch(format!("{}/{}", self.baseurl, path))
369                    .body(body.to_string());
370                for (key, val) in self.headers.clone() {
371                    req = req.header(key.to_string(), val.to_string());
372                }
373                tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await }))
374                    .map_err(Error::ReqwestError)
375            }
376            Err(e) => Err(Error::ReqwestError(e)),
377        }
378    }
379
380    pub fn body_patch(&mut self, path: &str, body: &str) -> crate::Result<String> {
381        let response = self.http_patch(path, body)?;
382        if !response.status().is_success() {
383            let status = response.status();
384            let text = tokio::task::block_in_place(|| {
385                Handle::current().block_on(async move { response.text().await })
386            })
387            .map_err(Error::ReqwestError)?;
388            return Err(Error::MethodFailed(
389                "Patch".to_string(),
390                status.as_u16(),
391                format!(
392                    "The server returned the error: {} {} | {text}",
393                    status.as_str(),
394                    status.canonical_reason().unwrap_or("unknown")
395                ),
396            ));
397        }
398        let text =
399            tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await }))
400                .map_err(Error::ReqwestError)?;
401        Ok(text)
402    }
403
404    pub fn json_patch(&mut self, path: &str, input: &Value) -> crate::Result<Value> {
405        let body = serde_json::to_string(input).map_err(Error::JsonError)?;
406        let text = self.body_patch(path, body.as_str())?;
407        let json = serde_json::from_str(&text).map_err(Error::JsonError)?;
408        Ok(json)
409    }
410
411    pub fn rhai_patch(&mut self, path: String, val: Dynamic) -> RhaiRes<Map> {
412        let body = if val.is_string() {
413            val.to_string()
414        } else {
415            match serde_json::to_string(&val) {
416                Ok(s) => s,
417                Err(e) => return Err(format!("Failed to serialize body: {e}").into()),
418            }
419        };
420        let mut ret = Map::new();
421        match self.http_patch(path.as_str(), &body) {
422            Ok(result) => {
423                ret.insert(
424                    "code".to_string().into(),
425                    Dynamic::from_int(result.status().as_u16().to_string().parse::<i64>().unwrap_or(0)),
426                );
427                tokio::task::block_in_place(|| {
428                    tokio::runtime::Handle::current().block_on(async {
429                        let headers = result
430                            .headers()
431                            .into_iter()
432                            .map(|(key, val)| {
433                                (
434                                    key.as_str().to_string(),
435                                    val.to_str().unwrap_or_default().to_string(),
436                                )
437                            })
438                            .collect::<Vec<(String, String)>>();
439                        let text = match result.text().await {
440                            Ok(t) => t,
441                            Err(e) => {
442                                ret.insert(
443                                    "body".to_string().into(),
444                                    Dynamic::from(format!("Error reading response body: {e}")),
445                                );
446                                ret.insert("json".to_string().into(), Dynamic::from(json!({})));
447                                ret.insert("headers".to_string().into(), Dynamic::from(headers));
448                                return Err(format!("Error reading response body: {e}").into());
449                            }
450                        };
451                        ret.insert(
452                            "json".to_string().into(),
453                            serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))),
454                        );
455                        ret.insert("headers".to_string().into(), Dynamic::from(headers.clone()));
456                        ret.insert("body".to_string().into(), Dynamic::from(text));
457                        Ok(ret)
458                    })
459                })
460            }
461            Err(e) => Err(crate::error_chain(&e).into()),
462        }
463    }
464
465    pub fn http_put(&mut self, path: &str, body: &str) -> crate::Result<Response> {
466        debug!("http_put '{}' ", format!("{}/{}", self.baseurl, path));
467        match self.get_client() {
468            Ok(client) => {
469                let mut req = client
470                    .put(format!("{}/{}", self.baseurl, path))
471                    .body(body.to_string());
472                for (key, val) in self.headers.clone() {
473                    req = req.header(key.to_string(), val.to_string());
474                }
475                tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await }))
476                    .map_err(Error::ReqwestError)
477            }
478            Err(e) => Err(Error::ReqwestError(e)),
479        }
480    }
481
482    pub fn body_put(&mut self, path: &str, body: &str) -> crate::Result<String> {
483        let response = self.http_put(path, body)?;
484        if !response.status().is_success() {
485            let status = response.status();
486            let text = tokio::task::block_in_place(|| {
487                Handle::current().block_on(async move { response.text().await })
488            })
489            .map_err(Error::ReqwestError)?;
490            return Err(Error::MethodFailed(
491                "Put".to_string(),
492                status.as_u16(),
493                format!(
494                    "The server returned the error: {} {} | {text}",
495                    status.as_str(),
496                    status.canonical_reason().unwrap_or("unknown")
497                ),
498            ));
499        }
500        let text =
501            tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await }))
502                .map_err(Error::ReqwestError)?;
503        Ok(text)
504    }
505
506    pub fn json_put(&mut self, path: &str, input: &Value) -> crate::Result<Value> {
507        let body = serde_json::to_string(input).map_err(Error::JsonError)?;
508        let text = self.body_put(path, body.as_str())?;
509        let json = serde_json::from_str(&text).map_err(Error::JsonError)?;
510        Ok(json)
511    }
512
513    pub fn rhai_put(&mut self, path: String, val: Dynamic) -> RhaiRes<Map> {
514        let body = if val.is_string() {
515            val.to_string()
516        } else {
517            match serde_json::to_string(&val) {
518                Ok(s) => s,
519                Err(e) => return Err(format!("Failed to serialize body: {e}").into()),
520            }
521        };
522        let mut ret = Map::new();
523        match self.http_put(path.as_str(), &body) {
524            Ok(result) => {
525                ret.insert(
526                    "code".to_string().into(),
527                    Dynamic::from_int(result.status().as_u16().to_string().parse::<i64>().unwrap_or(0)),
528                );
529                tokio::task::block_in_place(|| {
530                    tokio::runtime::Handle::current().block_on(async {
531                        let headers = result
532                            .headers()
533                            .into_iter()
534                            .map(|(key, val)| {
535                                (
536                                    key.as_str().to_string(),
537                                    val.to_str().unwrap_or_default().to_string(),
538                                )
539                            })
540                            .collect::<Vec<(String, String)>>();
541                        let text = match result.text().await {
542                            Ok(t) => t,
543                            Err(e) => {
544                                ret.insert(
545                                    "body".to_string().into(),
546                                    Dynamic::from(format!("Error reading response body: {e}")),
547                                );
548                                ret.insert("json".to_string().into(), Dynamic::from(json!({})));
549                                ret.insert("headers".to_string().into(), Dynamic::from(headers));
550                                return Err(format!("Error reading response body: {e}").into());
551                            }
552                        };
553                        ret.insert(
554                            "json".to_string().into(),
555                            serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))),
556                        );
557                        ret.insert("headers".to_string().into(), Dynamic::from(headers.clone()));
558                        ret.insert("body".to_string().into(), Dynamic::from(text));
559                        Ok(ret)
560                    })
561                })
562            }
563            Err(e) => Err(crate::error_chain(&e).into()),
564        }
565    }
566
567    pub fn http_post(&mut self, path: &str, body: &str) -> crate::Result<Response> {
568        debug!("http_post '{}' ", format!("{}/{}", self.baseurl, path));
569        match self.get_client() {
570            Ok(client) => {
571                let mut req = client
572                    .post(format!("{}/{}", self.baseurl, path))
573                    .body(body.to_string());
574                for (key, val) in self.headers.clone() {
575                    req = req.header(key.to_string(), val.to_string());
576                }
577                tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await }))
578                    .map_err(Error::ReqwestError)
579            }
580            Err(e) => Err(Error::ReqwestError(e)),
581        }
582    }
583
584    pub fn body_post(&mut self, path: &str, body: &str) -> crate::Result<String> {
585        let response = self.http_post(path, body)?;
586        if !response.status().is_success() {
587            let status = response.status();
588            let text = tokio::task::block_in_place(|| {
589                Handle::current().block_on(async move { response.text().await })
590            })
591            .map_err(Error::ReqwestError)?;
592            return Err(Error::MethodFailed(
593                "Post".to_string(),
594                status.as_u16(),
595                format!(
596                    "The server returned the error: {} {} | {text}",
597                    status.as_str(),
598                    status.canonical_reason().unwrap_or("unknown")
599                ),
600            ));
601        }
602        let text =
603            tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await }))
604                .map_err(Error::ReqwestError)?;
605        Ok(text)
606    }
607
608    pub fn json_post(&mut self, path: &str, input: &Value) -> crate::Result<Value> {
609        let body = serde_json::to_string(input).map_err(Error::JsonError)?;
610        let text = self.body_post(path, body.as_str())?;
611        let json = serde_json::from_str(&text).map_err(Error::JsonError)?;
612        Ok(json)
613    }
614
615    pub fn rhai_post(&mut self, path: String, val: Dynamic) -> RhaiRes<Map> {
616        let body = if val.is_string() {
617            val.to_string()
618        } else {
619            match serde_json::to_string(&val) {
620                Ok(s) => s,
621                Err(e) => return Err(format!("Failed to serialize body: {e}").into()),
622            }
623        };
624        let mut ret = Map::new();
625        match self.http_post(path.as_str(), &body) {
626            Ok(result) => {
627                ret.insert(
628                    "code".to_string().into(),
629                    Dynamic::from_int(result.status().as_u16().to_string().parse::<i64>().unwrap_or(0)),
630                );
631                tokio::task::block_in_place(|| {
632                    tokio::runtime::Handle::current().block_on(async {
633                        let headers = result
634                            .headers()
635                            .into_iter()
636                            .map(|(key, val)| {
637                                (
638                                    key.as_str().to_string(),
639                                    val.to_str().unwrap_or_default().to_string(),
640                                )
641                            })
642                            .collect::<Vec<(String, String)>>();
643                        let text = match result.text().await {
644                            Ok(t) => t,
645                            Err(e) => {
646                                ret.insert(
647                                    "body".to_string().into(),
648                                    Dynamic::from(format!("Error reading response body: {e}")),
649                                );
650                                ret.insert("json".to_string().into(), Dynamic::from(json!({})));
651                                ret.insert("headers".to_string().into(), Dynamic::from(headers));
652                                return Err(format!("Error reading response body: {e}").into());
653                            }
654                        };
655                        ret.insert(
656                            "json".to_string().into(),
657                            serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))),
658                        );
659                        ret.insert("headers".to_string().into(), Dynamic::from(headers.clone()));
660                        ret.insert("body".to_string().into(), Dynamic::from(text));
661                        Ok(ret)
662                    })
663                })
664            }
665            Err(e) => Err(crate::error_chain(&e).into()),
666        }
667    }
668
669    pub fn http_post_form(&mut self, path: &str, params: &[(String, String)]) -> crate::Result<Response> {
670        debug!("http_post_form '{}' ", format!("{}/{}", self.baseurl, path));
671        match self.get_client() {
672            Ok(client) => {
673                let mut req = client.post(format!("{}/{}", self.baseurl, path)).form(params);
674                for (key, val) in self.headers.clone() {
675                    if key.as_str() != "Content-Type" {
676                        req = req.header(key.to_string(), val.to_string());
677                    }
678                }
679                tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await }))
680                    .map_err(Error::ReqwestError)
681            }
682            Err(e) => Err(Error::ReqwestError(e)),
683        }
684    }
685
686    pub fn rhai_post_form(&mut self, path: String, val: Map) -> RhaiRes<Map> {
687        let params: Vec<(String, String)> = val
688            .into_iter()
689            .map(|(k, v)| (k.to_string(), v.to_string()))
690            .collect();
691        let mut ret = Map::new();
692        match self.http_post_form(path.as_str(), &params) {
693            Ok(result) => {
694                ret.insert(
695                    "code".to_string().into(),
696                    Dynamic::from_int(result.status().as_u16().to_string().parse::<i64>().unwrap_or(0)),
697                );
698                tokio::task::block_in_place(|| {
699                    tokio::runtime::Handle::current().block_on(async {
700                        let headers = result
701                            .headers()
702                            .into_iter()
703                            .map(|(key, val)| {
704                                (
705                                    key.as_str().to_string(),
706                                    val.to_str().unwrap_or_default().to_string(),
707                                )
708                            })
709                            .collect::<Vec<(String, String)>>();
710                        let text = match result.text().await {
711                            Ok(t) => t,
712                            Err(e) => {
713                                ret.insert(
714                                    "body".to_string().into(),
715                                    Dynamic::from(format!("Error reading response body: {e}")),
716                                );
717                                ret.insert("json".to_string().into(), Dynamic::from(json!({})));
718                                ret.insert("headers".to_string().into(), Dynamic::from(headers));
719                                return Err(format!("Error reading response body: {e}").into());
720                            }
721                        };
722                        ret.insert(
723                            "json".to_string().into(),
724                            serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))),
725                        );
726                        ret.insert("headers".to_string().into(), Dynamic::from(headers.clone()));
727                        ret.insert("body".to_string().into(), Dynamic::from(text));
728                        Ok(ret)
729                    })
730                })
731            }
732            Err(e) => Err(crate::error_chain(&e).into()),
733        }
734    }
735
736    pub fn http_delete(&mut self, path: &str) -> crate::Result<Response> {
737        debug!("http_delete '{}' ", format!("{}/{}", self.baseurl, path));
738        match self.get_client() {
739            Ok(client) => {
740                let mut req = client.delete(format!("{}/{}", self.baseurl, path));
741                for (key, val) in self.headers.clone() {
742                    req = req.header(key.to_string(), val.to_string());
743                }
744                tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await }))
745                    .map_err(Error::ReqwestError)
746            }
747            Err(e) => Err(Error::ReqwestError(e)),
748        }
749    }
750
751    pub fn body_delete(&mut self, path: &str) -> crate::Result<String> {
752        let response = self.http_delete(path)?;
753        if !response.status().is_success() && response.status() != reqwest::StatusCode::NOT_FOUND {
754            let status = response.status();
755            let text = tokio::task::block_in_place(|| {
756                Handle::current().block_on(async move { response.text().await })
757            })
758            .map_err(Error::ReqwestError)?;
759            return Err(Error::MethodFailed(
760                "Delete".to_string(),
761                status.as_u16(),
762                format!(
763                    "The server returned the error: {} {} | {text}",
764                    status.as_str(),
765                    status.canonical_reason().unwrap_or("unknown")
766                ),
767            ));
768        }
769        let text =
770            tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await }))
771                .map_err(Error::ReqwestError)?;
772        Ok(text)
773    }
774
775    pub fn json_delete(&mut self, path: &str) -> crate::Result<Value> {
776        let text = self.body_delete(path)?;
777        let json =
778            serde_json::from_str(&text).or_else(|_| Ok::<serde_json::Value, Error>(json!({"body": text})))?;
779        Ok(json)
780    }
781
782    pub fn rhai_delete(&mut self, path: String) -> RhaiRes<Map> {
783        let mut ret = Map::new();
784        match self.http_delete(path.as_str()) {
785            Ok(result) => {
786                ret.insert(
787                    "code".to_string().into(),
788                    Dynamic::from_int(result.status().as_u16().to_string().parse::<i64>().unwrap_or(0)),
789                );
790                tokio::task::block_in_place(|| {
791                    tokio::runtime::Handle::current().block_on(async {
792                        let headers = result
793                            .headers()
794                            .into_iter()
795                            .map(|(key, val)| {
796                                (
797                                    key.as_str().to_string(),
798                                    val.to_str().unwrap_or_default().to_string(),
799                                )
800                            })
801                            .collect::<Vec<(String, String)>>();
802                        let text = match result.text().await {
803                            Ok(t) => t,
804                            Err(e) => {
805                                ret.insert(
806                                    "body".to_string().into(),
807                                    Dynamic::from(format!("Error reading response body: {e}")),
808                                );
809                                ret.insert("json".to_string().into(), Dynamic::from(json!({})));
810                                ret.insert("headers".to_string().into(), Dynamic::from(headers));
811                                return Err(format!("Error reading response body: {e}").into());
812                            }
813                        };
814                        ret.insert(
815                            "json".to_string().into(),
816                            serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))),
817                        );
818                        ret.insert("headers".to_string().into(), Dynamic::from(headers.clone()));
819                        ret.insert("body".to_string().into(), Dynamic::from(text));
820                        Ok(ret)
821                    })
822                })
823            }
824            Err(e) => Err(crate::error_chain(&e).into()),
825        }
826    }
827
828    pub fn obj_read(&mut self, method: ReadMethod, path: &str, key: &str) -> crate::Result<Value> {
829        let full_path = if key.is_empty() {
830            path.to_string()
831        } else {
832            format!("{path}/{key}")
833        };
834        if method == ReadMethod::Get {
835            self.json_get(&full_path)
836        } else {
837            Err(UnsupportedMethod)
838        }
839    }
840
841    pub fn obj_create(&mut self, method: CreateMethod, path: &str, input: &Value) -> crate::Result<Value> {
842        if method == CreateMethod::Post {
843            self.json_post(path, input)
844        } else if method == CreateMethod::Put {
845            self.json_put(path, input)
846        } else {
847            Err(UnsupportedMethod)
848        }
849    }
850
851    pub fn obj_update(
852        &mut self,
853        method: UpdateMethod,
854        path: &str,
855        key: &str,
856        input: &Value,
857        use_slash: bool,
858    ) -> crate::Result<Value> {
859        let full_path = if key.is_empty() {
860            path.to_string()
861        } else if use_slash {
862            format!("{path}/{key}/")
863        } else {
864            format!("{path}/{key}")
865        };
866        if method == UpdateMethod::Patch {
867            self.json_patch(&full_path, input)
868        } else if method == UpdateMethod::Put {
869            self.json_put(&full_path, input)
870        } else if method == UpdateMethod::Post {
871            self.json_post(&full_path, input)
872        } else if method == UpdateMethod::None {
873            Ok(input.clone())
874        } else {
875            Err(UnsupportedMethod)
876        }
877    }
878
879    pub fn obj_delete(&mut self, method: DeleteMethod, path: &str, key: &str) -> crate::Result<Value> {
880        let full_path = if key.is_empty() {
881            path.to_string()
882        } else {
883            format!("{path}/{key}")
884        };
885        if method == DeleteMethod::Delete {
886            self.json_delete(&full_path)
887        } else {
888            Err(UnsupportedMethod)
889        }
890    }
891
892    pub fn http_delete_with_body(&mut self, path: &str, body: &str) -> crate::Result<Response> {
893        debug!(
894            "http_delete_with_body '{}' ",
895            format!("{}/{}", self.baseurl, path)
896        );
897        match self.get_client() {
898            Ok(client) => {
899                let mut req = client
900                    .delete(format!("{}/{}", self.baseurl, path))
901                    .body(body.to_string());
902                for (key, val) in self.headers.clone() {
903                    req = req.header(key.to_string(), val.to_string());
904                }
905                tokio::task::block_in_place(|| Handle::current().block_on(async move { req.send().await }))
906                    .map_err(Error::ReqwestError)
907            }
908            Err(e) => Err(Error::ReqwestError(e)),
909        }
910    }
911
912    pub fn body_delete_with_body(&mut self, path: &str, body: &str) -> crate::Result<String> {
913        let response = self.http_delete_with_body(path, body)?;
914        if !response.status().is_success() && response.status() != reqwest::StatusCode::NOT_FOUND {
915            let status = response.status();
916            let text = tokio::task::block_in_place(|| {
917                Handle::current().block_on(async move { response.text().await })
918            })
919            .map_err(Error::ReqwestError)?;
920            return Err(Error::MethodFailed(
921                "Delete".to_string(),
922                status.as_u16(),
923                format!(
924                    "The server returned the error: {} {} | {text}",
925                    status.as_str(),
926                    status.canonical_reason().unwrap_or("unknown")
927                ),
928            ));
929        }
930        let text =
931            tokio::task::block_in_place(|| Handle::current().block_on(async move { response.text().await }))
932                .map_err(Error::ReqwestError)?;
933        Ok(text)
934    }
935
936    pub fn json_delete_with_body(&mut self, path: &str, input: &Value) -> crate::Result<Value> {
937        let body = serde_json::to_string(input).map_err(Error::JsonError)?;
938        let text = self.body_delete_with_body(path, body.as_str())?;
939        let json =
940            serde_json::from_str(&text).or_else(|_| Ok::<serde_json::Value, Error>(json!({"body": text})))?;
941        Ok(json)
942    }
943
944    pub fn rhai_delete_with_body(&mut self, path: String, val: Dynamic) -> RhaiRes<Map> {
945        let body = if val.is_string() {
946            val.to_string()
947        } else {
948            match serde_json::to_string(&val) {
949                Ok(s) => s,
950                Err(e) => return Err(format!("Failed to serialize body: {e}").into()),
951            }
952        };
953        let mut ret = Map::new();
954        match self.http_delete_with_body(path.as_str(), &body) {
955            Ok(result) => {
956                ret.insert(
957                    "code".to_string().into(),
958                    Dynamic::from_int(result.status().as_u16().to_string().parse::<i64>().unwrap_or(0)),
959                );
960                tokio::task::block_in_place(|| {
961                    tokio::runtime::Handle::current().block_on(async {
962                        let headers = result
963                            .headers()
964                            .into_iter()
965                            .map(|(key, val)| {
966                                (
967                                    key.as_str().to_string(),
968                                    val.to_str().unwrap_or_default().to_string(),
969                                )
970                            })
971                            .collect::<Vec<(String, String)>>();
972                        let text = match result.text().await {
973                            Ok(t) => t,
974                            Err(e) => {
975                                ret.insert(
976                                    "body".to_string().into(),
977                                    Dynamic::from(format!("Error reading response body: {e}")),
978                                );
979                                ret.insert("json".to_string().into(), Dynamic::from(json!({})));
980                                ret.insert("headers".to_string().into(), Dynamic::from(headers));
981                                return Err(format!("Error reading response body: {e}").into());
982                            }
983                        };
984                        ret.insert(
985                            "json".to_string().into(),
986                            serde_json::from_str(&text).unwrap_or(Dynamic::from(json!({}))),
987                        );
988                        ret.insert("headers".to_string().into(), Dynamic::from(headers.clone()));
989                        ret.insert("body".to_string().into(), Dynamic::from(text));
990                        Ok(ret)
991                    })
992                })
993            }
994            Err(e) => Err(crate::error_chain(&e).into()),
995        }
996    }
997
998    pub fn obj_delete_with_body(
999        &mut self,
1000        method: DeleteMethod,
1001        path: &str,
1002        input: &Value,
1003    ) -> crate::Result<Value> {
1004        if method == DeleteMethod::Delete {
1005            self.json_delete_with_body(path, input)
1006        } else {
1007            Err(UnsupportedMethod)
1008        }
1009    }
1010}
1011
1012/// Case-insensitive lookup of a single response header by name. `headers` is the opaque
1013/// `Vec<(String, String)>` returned as the `headers` field of `get`/`post`/... results — it
1014/// carries no rhai-visible indexing or iteration of its own, so this is the only way to read
1015/// a specific header from a script. Returns `()` when the header is absent.
1016pub fn headers_get(headers: Vec<(String, String)>, name: String) -> Dynamic {
1017    headers
1018        .iter()
1019        .find(|(k, _)| k.eq_ignore_ascii_case(&name))
1020        .map_or(Dynamic::UNIT, |(_, v)| Dynamic::from(v.clone()))
1021}
1022
1023/// Case-insensitive presence check for a response header by name. See [`headers_get`].
1024pub fn headers_has(headers: Vec<(String, String)>, name: String) -> bool {
1025    headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(&name))
1026}
1027
1028pub fn http_get_yaml(url: String, auth_type: String, credential: String) -> RhaiRes<Dynamic> {
1029    tokio::task::block_in_place(|| {
1030        Handle::current().block_on(async move {
1031            let mut headers = reqwest::header::HeaderMap::new();
1032            match auth_type.as_str() {
1033                "bearer" => {
1034                    headers.insert(
1035                        reqwest::header::AUTHORIZATION,
1036                        format!("Bearer {}", credential).parse().unwrap(),
1037                    );
1038                }
1039                "basic" => {
1040                    let encoded = STANDARD.encode(&credential);
1041                    headers.insert(
1042                        reqwest::header::AUTHORIZATION,
1043                        format!("Basic {}", encoded).parse().unwrap(),
1044                    );
1045                }
1046                _ => {}
1047            }
1048            let client = reqwest::Client::builder()
1049                .default_headers(headers)
1050                .timeout(std::time::Duration::from_secs(300))
1051                .build()
1052                .map_err(|e| Error::Other(e.to_string()))?;
1053            let response = client.get(&url).send().await.map_err(Error::ReqwestError)?;
1054            if !response.status().is_success() {
1055                return Err(Error::Other(format!(
1056                    "SCAN-HTTP-001: HTTP {} for {}",
1057                    response.status(),
1058                    url
1059                )));
1060            }
1061            let body = response.text().await.map_err(Error::ReqwestError)?;
1062            let value: serde_yaml::Value =
1063                serde_yaml::from_str(&body).map_err(|e| Error::YamlError(e.to_string()))?;
1064            let json = serde_json::to_string(&value).map_err(Error::SerializationError)?;
1065            serde_json::from_str::<Dynamic>(&json).map_err(Error::SerializationError)
1066        })
1067    })
1068    .map_err(rhai_err)
1069}
1070
1071pub fn http_rhai_register(engine: &mut Engine) {
1072    engine
1073        .register_type_with_name::<RestClient>("RestClient")
1074        .register_fn("new_http_client", RestClient::new)
1075        .register_fn("new_client", RestClient::new)
1076        .register_fn("headers_reset", RestClient::headers_reset_rhai)
1077        .register_fn("set_baseurl", RestClient::baseurl_rhai)
1078        .register_fn("set_server_ca", RestClient::set_server_ca)
1079        .register_fn("set_mtls_cert_key", RestClient::set_mtls)
1080        .register_fn("add_header", RestClient::add_header_rhai)
1081        .register_fn("add_header_json", RestClient::add_header_json)
1082        .register_fn("add_header_bearer", RestClient::add_header_bearer)
1083        .register_fn("add_header_basic", RestClient::add_header_basic)
1084        .register_fn("head", RestClient::rhai_head)
1085        .register_fn("get", RestClient::rhai_get)
1086        .register_fn("http_get", RestClient::rhai_get)
1087        .register_fn("delete", RestClient::rhai_delete)
1088        .register_fn("http_delete", RestClient::rhai_delete)
1089        .register_fn("delete_with_body", RestClient::rhai_delete_with_body)
1090        .register_fn("http_delete_with_body", RestClient::rhai_delete_with_body)
1091        .register_fn("patch", RestClient::rhai_patch)
1092        .register_fn("http_patch", RestClient::rhai_patch)
1093        .register_fn("post", RestClient::rhai_post)
1094        .register_fn("http_post", RestClient::rhai_post)
1095        .register_fn("put", RestClient::rhai_put)
1096        .register_fn("http_put", RestClient::rhai_put)
1097        .register_fn("post_form", RestClient::rhai_post_form)
1098        .register_fn("http_post_form", RestClient::rhai_post_form)
1099        .register_fn("http_get_yaml", http_get_yaml)
1100        .register_fn("headers_get", headers_get)
1101        .register_fn("headers_has", headers_has);
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107    use wiremock::{
1108        Mock, MockServer, ResponseTemplate,
1109        matchers::{header, method, path},
1110    };
1111
1112    #[tokio::test(flavor = "multi_thread")]
1113    async fn rhai_get_on_connection_failure_surfaces_real_cause() {
1114        // Regression test for a JukeBox `Gitlab` source whose scan silently failed with the
1115        // opaque `error sending request for url (...)` message and nothing else: connection-level
1116        // reqwest errors (DNS, TLS, refused, timeout) only expose their real cause via `source()`,
1117        // not `Display`. `rhai_get`'s error branch must walk that chain instead of `format!("{e}")`,
1118        // or an operator has no way to tell a network/proxy problem from a GitLab API rejection.
1119        crate::set_client_name(|| "vynil-core-tests".to_string());
1120        let mut client = RestClient::new("http://127.0.0.1:1");
1121        let err = client
1122            .rhai_get("api/v4/projects?search=box&per_page=20".to_string())
1123            .expect_err("port 1 should refuse the connection");
1124        let message = err.to_string();
1125        assert!(
1126            message.contains("error sending request for url (http://127.0.0.1:1/api/v4/projects"),
1127            "message should still carry reqwest's own text: {message}"
1128        );
1129        assert!(
1130            message.contains("Connection refused") || message.contains("refused"),
1131            "message must carry the real transport-level cause, not just the opaque reqwest \
1132             wrapper, or the bug is back: {message}"
1133        );
1134    }
1135
1136    #[tokio::test(flavor = "multi_thread")]
1137    async fn test_http_get_yaml_ok() {
1138        let server = MockServer::start().await;
1139        Mock::given(method("GET"))
1140            .and(path("/index.yaml"))
1141            .respond_with(ResponseTemplate::new(200).set_body_string("packages:\n  - name: test\n"))
1142            .mount(&server)
1143            .await;
1144
1145        let result = http_get_yaml(
1146            format!("{}/index.yaml", server.uri()),
1147            String::new(),
1148            String::new(),
1149        );
1150        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1151        let d = result.unwrap();
1152        assert!(d.is_map(), "expected map Dynamic");
1153    }
1154
1155    #[tokio::test(flavor = "multi_thread")]
1156    async fn test_http_get_yaml_404() {
1157        let server = MockServer::start().await;
1158        Mock::given(method("GET"))
1159            .and(path("/missing.yaml"))
1160            .respond_with(ResponseTemplate::new(404))
1161            .mount(&server)
1162            .await;
1163
1164        let result = http_get_yaml(
1165            format!("{}/missing.yaml", server.uri()),
1166            String::new(),
1167            String::new(),
1168        );
1169        assert!(result.is_err());
1170        let err = format!("{:?}", result.unwrap_err());
1171        assert!(
1172            err.contains("SCAN-HTTP-001"),
1173            "error should contain SCAN-HTTP-001: {err}"
1174        );
1175    }
1176
1177    #[tokio::test(flavor = "multi_thread")]
1178    async fn test_http_get_yaml_bearer() {
1179        let server = MockServer::start().await;
1180        Mock::given(method("GET"))
1181            .and(path("/index.yaml"))
1182            .and(header("authorization", "Bearer token123"))
1183            .respond_with(ResponseTemplate::new(200).set_body_string("key: value\n"))
1184            .mount(&server)
1185            .await;
1186
1187        let result = http_get_yaml(
1188            format!("{}/index.yaml", server.uri()),
1189            "bearer".to_string(),
1190            "token123".to_string(),
1191        );
1192        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1193    }
1194
1195    #[tokio::test(flavor = "multi_thread")]
1196    async fn test_http_get_yaml_basic() {
1197        let server = MockServer::start().await;
1198        Mock::given(method("GET"))
1199            .and(path("/index.yaml"))
1200            .and(header("authorization", "Basic dXNlcjpwYXNz"))
1201            .respond_with(ResponseTemplate::new(200).set_body_string("key: value\n"))
1202            .mount(&server)
1203            .await;
1204
1205        let result = http_get_yaml(
1206            format!("{}/index.yaml", server.uri()),
1207            "basic".to_string(),
1208            "user:pass".to_string(),
1209        );
1210        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1211    }
1212
1213    #[test]
1214    fn test_headers_get_finds_value_case_insensitively() {
1215        let headers = vec![("X-Total-Pages".to_string(), "3".to_string())];
1216        let found = headers_get(headers, "x-total-pages".to_string());
1217        assert_eq!(found.into_string().unwrap(), "3");
1218    }
1219
1220    #[test]
1221    fn test_headers_get_missing_returns_unit() {
1222        let headers = vec![("content-type".to_string(), "application/json".to_string())];
1223        let found = headers_get(headers, "x-total-pages".to_string());
1224        assert!(
1225            found.is_unit(),
1226            "expected unit for a missing header, got {found:?}"
1227        );
1228    }
1229
1230    #[test]
1231    fn test_headers_has_case_insensitive() {
1232        let headers = vec![("X-Total-Pages".to_string(), "3".to_string())];
1233        assert!(headers_has(headers.clone(), "x-total-pages".to_string()));
1234        assert!(!headers_has(headers, "x-total-count".to_string()));
1235    }
1236}