pact_broker_cli/cli/pact_broker/
main.rs

1//! Structs and functions for interacting with a Pact Broker
2
3use std::collections::HashMap;
4use std::ops::Not;
5use std::panic::RefUnwindSafe;
6use std::str::from_utf8;
7
8use anyhow::anyhow;
9use futures::stream::*;
10
11use itertools::Itertools;
12use maplit::hashmap;
13
14use pact_models::http_utils;
15use pact_models::http_utils::HttpAuth;
16use pact_models::json_utils::json_to_string;
17
18#[derive(Debug, Clone)]
19pub struct CustomHeaders {
20    pub headers: std::collections::HashMap<String, String>,
21}
22use pact_models::pact::{Pact, load_pact_from_json};
23use regex::{Captures, Regex};
24use reqwest::{Method, Url};
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27use serde_with::skip_serializing_none;
28use tracing::{debug, error, info, trace, warn};
29pub mod branches;
30pub mod can_i_deploy;
31pub mod deployments;
32pub mod environments;
33pub mod pact_publish;
34pub mod pacticipants;
35pub mod pacts;
36pub mod provider_states;
37pub mod subcommands;
38pub mod tags;
39#[cfg(test)]
40pub mod test_utils;
41pub mod types;
42pub mod utils;
43pub mod verification;
44pub mod versions;
45pub mod webhooks;
46use utils::with_retries;
47// for otel
48use crate::cli::utils::{CYAN, GREEN, RED, YELLOW};
49use http::Extensions;
50use opentelemetry::Context;
51use opentelemetry::global;
52use opentelemetry_http::HeaderInjector;
53use reqwest::Request;
54use reqwest::Response;
55use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
56use reqwest_middleware::{Middleware, Next};
57use reqwest_tracing::TracingMiddleware;
58
59use crate::cli::pact_broker::main::types::SslOptions;
60
61pub fn process_notices(notices: &[Notice]) {
62    for notice in notices {
63        let notice_text = notice.text.to_string();
64        let formatted_text = notice_text
65            .split_whitespace()
66            .map(|word| {
67                if word.starts_with("https") || word.starts_with("http") {
68                    format!("{}", CYAN.apply_to(word))
69                } else {
70                    match notice.type_field.as_str() {
71                        "success" => format!("{}", GREEN.apply_to(word)),
72                        "warning" | "prompt" => format!("{}", YELLOW.apply_to(word)),
73                        "error" | "danger" => format!("{}", RED.apply_to(word)),
74                        _ => word.to_string(),
75                    }
76                }
77            })
78            .collect::<Vec<String>>()
79            .join(" ");
80        println!("{}", formatted_text);
81    }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85#[serde(rename_all = "camelCase")]
86pub struct Notice {
87    pub text: String,
88    #[serde(rename = "type")]
89    pub type_field: String,
90}
91
92fn is_true(object: &serde_json::Map<String, Value>, field: &str) -> bool {
93    match object.get(field) {
94        Some(json) => match *json {
95            serde_json::Value::Bool(b) => b,
96            _ => false,
97        },
98        None => false,
99    }
100}
101
102fn as_string(json: &Value) -> String {
103    match *json {
104        serde_json::Value::String(ref s) => s.clone(),
105        _ => format!("{}", json),
106    }
107}
108
109fn content_type(response: &reqwest::Response) -> String {
110    match response.headers().get("content-type") {
111        Some(value) => value.to_str().unwrap_or("text/plain").into(),
112        None => "text/plain".to_string(),
113    }
114}
115
116fn json_content_type(response: &reqwest::Response) -> bool {
117    match content_type(response).parse::<mime::Mime>() {
118        Ok(mime) => {
119            match (
120                mime.type_().as_str(),
121                mime.subtype().as_str(),
122                mime.suffix(),
123            ) {
124                ("application", "json", None) => true,
125                ("application", "hal", Some(mime::JSON)) => true,
126                _ => false,
127            }
128        }
129        Err(_) => false,
130    }
131}
132
133fn find_entry(map: &serde_json::Map<String, Value>, key: &str) -> Option<(String, Value)> {
134    match map.keys().find(|k| k.to_lowercase() == key.to_lowercase()) {
135        Some(k) => map.get(k).map(|v| (key.to_string(), v.clone())),
136        None => None,
137    }
138}
139
140/// Errors that can occur with a Pact Broker
141#[derive(Debug, Clone, thiserror::Error)]
142pub enum PactBrokerError {
143    /// Error with a HAL link
144    #[error("Error with a HAL link - {0}")]
145    LinkError(String),
146    /// Error with the content of a HAL resource
147    #[error("Error with the content of a HAL resource - {0}")]
148    ContentError(String),
149    #[error("IO Error - {0}")]
150    /// IO Error
151    IoError(String),
152    /// Link/Resource was not found
153    #[error("Link/Resource was not found - {0}")]
154    NotFound(String),
155    /// Invalid URL
156    #[error("Invalid URL - {0}")]
157    UrlError(String),
158    /// Validation error
159    #[error("failed validation - {0:?}")]
160    ValidationError(Vec<String>),
161    /// Validation error with notices
162    #[error("failed validation - {0:?}")]
163    ValidationErrorWithNotices(Vec<String>, Vec<Notice>),
164}
165
166impl PartialEq<String> for PactBrokerError {
167    fn eq(&self, other: &String) -> bool {
168        let mut buffer = String::new();
169        match self {
170            PactBrokerError::LinkError(s) => buffer.push_str(s),
171            PactBrokerError::ContentError(s) => buffer.push_str(s),
172            PactBrokerError::IoError(s) => buffer.push_str(s),
173            PactBrokerError::NotFound(s) => buffer.push_str(s),
174            PactBrokerError::UrlError(s) => buffer.push_str(s),
175            PactBrokerError::ValidationError(errors) => {
176                buffer.push_str(errors.iter().join(", ").as_str())
177            }
178            PactBrokerError::ValidationErrorWithNotices(errors, _) => {
179                buffer.push_str(errors.iter().join(", ").as_str())
180            }
181        };
182        buffer == *other
183    }
184}
185
186impl<'a> PartialEq<&'a str> for PactBrokerError {
187    fn eq(&self, other: &&str) -> bool {
188        let message = match self {
189            PactBrokerError::LinkError(s) => s.clone(),
190            PactBrokerError::ContentError(s) => s.clone(),
191            PactBrokerError::IoError(s) => s.clone(),
192            PactBrokerError::NotFound(s) => s.clone(),
193            PactBrokerError::UrlError(s) => s.clone(),
194            PactBrokerError::ValidationError(errors) => errors.iter().join(", "),
195            PactBrokerError::ValidationErrorWithNotices(errors, _) => errors.iter().join(", "),
196        };
197        message.as_str() == *other
198    }
199}
200
201impl From<url::ParseError> for PactBrokerError {
202    fn from(err: url::ParseError) -> Self {
203        PactBrokerError::UrlError(format!("{}", err))
204    }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(default)]
209/// Structure to represent a HAL link
210pub struct Link {
211    /// Link name
212    pub name: String,
213    /// Link HREF
214    pub href: Option<String>,
215    /// If the link is templated (has expressions in the HREF that need to be expanded)
216    pub templated: bool,
217    /// Link title
218    pub title: Option<String>,
219}
220
221impl Link {
222    /// Create a link from serde JSON data
223    pub fn from_json(link: &str, link_data: &serde_json::Map<String, serde_json::Value>) -> Link {
224        Link {
225            name: link.to_string(),
226            href: find_entry(link_data, &"href".to_string()).map(|(_, href)| as_string(&href)),
227            templated: is_true(link_data, "templated"),
228            title: link_data.get("title").map(|title| as_string(title)),
229        }
230    }
231
232    /// Converts the Link into a JSON representation
233    pub fn as_json(&self) -> serde_json::Value {
234        match (self.href.clone(), self.title.clone()) {
235            (Some(href), Some(title)) => json!({
236              "href": href,
237              "title": title,
238              "templated": self.templated
239            }),
240            (Some(href), None) => json!({
241              "href": href,
242              "templated": self.templated
243            }),
244            (None, Some(title)) => json!({
245              "title": title,
246              "templated": self.templated
247            }),
248            (None, None) => json!({
249              "templated": self.templated
250            }),
251        }
252    }
253}
254
255impl Default for Link {
256    fn default() -> Self {
257        Link {
258            name: "link".to_string(),
259            href: None,
260            templated: false,
261            title: None,
262        }
263    }
264}
265
266/// HAL aware HTTP client
267#[derive(Clone)]
268pub struct HALClient {
269    pub url: String,
270    pub client: ClientWithMiddleware,
271    path_info: Option<Value>,
272    auth: Option<HttpAuth>,
273    custom_headers: Option<CustomHeaders>,
274    ssl_options: SslOptions,
275    pub retries: u8,
276}
277
278struct OtelPropagatorMiddleware;
279
280#[async_trait::async_trait]
281impl Middleware for OtelPropagatorMiddleware {
282    async fn handle(
283        &self,
284        mut req: Request,
285        extensions: &mut Extensions,
286        next: Next<'_>,
287    ) -> reqwest_middleware::Result<Response> {
288        let cx = Context::current();
289        let mut headers = reqwest::header::HeaderMap::new();
290        global::get_text_map_propagator(|propagator| {
291            propagator.inject_context(&cx, &mut HeaderInjector(&mut headers))
292        });
293        headers.append(
294            "baggage",
295            reqwest::header::HeaderValue::from_static("is_synthetic=true"),
296        );
297
298        for (key, value) in headers.iter() {
299            req.headers_mut().append(key, value.clone());
300        }
301        let res = next.run(req, extensions).await;
302        res
303    }
304}
305
306pub trait WithCurrentSpan {
307    fn with_current_span<F, R>(&self, f: F) -> R
308    where
309        F: FnOnce() -> R;
310}
311
312impl<T> WithCurrentSpan for T {
313    fn with_current_span<F, R>(&self, f: F) -> R
314    where
315        F: FnOnce() -> R,
316    {
317        let span = tracing::Span::current();
318        let _enter = span.enter();
319        f()
320    }
321}
322
323impl HALClient {
324    /// Helper method to apply custom headers to a request builder
325    fn apply_custom_headers(
326        &self,
327        mut builder: reqwest_middleware::RequestBuilder,
328    ) -> reqwest_middleware::RequestBuilder {
329        if let Some(ref custom_headers) = self.custom_headers {
330            for (name, value) in &custom_headers.headers {
331                builder = builder.header(name, value);
332            }
333        }
334        builder
335    }
336
337    /// Initialise a client with the URL and authentication
338    pub fn with_url(
339        url: &str,
340        auth: Option<HttpAuth>,
341        ssl_options: SslOptions,
342        custom_headers: Option<CustomHeaders>,
343    ) -> HALClient {
344        HALClient {
345            url: url.to_string(),
346            auth: auth.clone(),
347            custom_headers,
348            ssl_options: ssl_options.clone(),
349            ..HALClient::setup(url, auth, ssl_options)
350        }
351    }
352
353    fn update_path_info(self, path_info: serde_json::Value) -> HALClient {
354        HALClient {
355            client: self.client.clone(),
356            url: self.url.clone(),
357            path_info: Some(path_info),
358            auth: self.auth,
359            custom_headers: self.custom_headers,
360            retries: self.retries,
361            ssl_options: self.ssl_options,
362        }
363    }
364
365    /// Navigate to the resource from the link name
366    pub async fn navigate(
367        self,
368        link: &'static str,
369        template_values: &HashMap<String, String>,
370    ) -> Result<HALClient, PactBrokerError> {
371        trace!(
372            "navigate(link='{}', template_values={:?})",
373            link, template_values
374        );
375
376        let client = if self.path_info.is_none() {
377            let path_info = self.clone().fetch("/".into()).await?;
378            self.update_path_info(path_info)
379        } else {
380            self
381        };
382
383        let path_info = client.clone().fetch_link(link, template_values).await?;
384        let client = client.update_path_info(path_info);
385
386        Ok(client)
387    }
388
389    fn find_link(&self, link: &'static str) -> Result<Link, PactBrokerError> {
390        match self.path_info {
391            None => Err(PactBrokerError::LinkError(format!("No previous resource has been fetched from the pact broker. URL: '{}', LINK: '{}'",
392                self.url, link))),
393            Some(ref json) => match json.get("_links") {
394                Some(json) => match json.get(link) {
395                    Some(link_data) => link_data.as_object()
396                        .map(|link_data| Link::from_json(&link.to_string(), &link_data))
397                        .ok_or_else(|| PactBrokerError::LinkError(format!("Link is malformed, expected an object but got {}. URL: '{}', LINK: '{}'",
398                            link_data, self.url, link))),
399                    None => Err(PactBrokerError::LinkError(format!("Link '{}' was not found in the response, only the following links where found: {:?}. URL: '{}', LINK: '{}'",
400                        link, json.as_object().unwrap_or(&json!({}).as_object().unwrap()).keys().join(", "), self.url, link)))
401                },
402                None => Err(PactBrokerError::LinkError(format!("Expected a HAL+JSON response from the pact broker, but got a response with no '_links'. URL: '{}', LINK: '{}'",
403                    self.url, link)))
404            }
405        }
406    }
407
408    async fn fetch_link(
409        self,
410        link: &'static str,
411        template_values: &HashMap<String, String>,
412    ) -> Result<Value, PactBrokerError> {
413        trace!(
414            "fetch_link(link='{}', template_values={:?})",
415            link, template_values
416        );
417
418        let link_data = self.find_link(link)?;
419
420        self.fetch_url(&link_data, template_values).await
421    }
422
423    /// Fetch the resource at the Link from the Pact broker
424    pub async fn fetch_url(
425        self,
426        link: &Link,
427        template_values: &HashMap<String, String>,
428    ) -> Result<Value, PactBrokerError> {
429        debug!(
430            "fetch_url(link={:?}, template_values={:?})",
431            link, template_values
432        );
433
434        let link_url = if link.templated {
435            debug!("Link URL is templated");
436            self.clone().parse_link_url(&link, &template_values)
437        } else {
438            link.href.clone().ok_or_else(|| {
439                PactBrokerError::LinkError(format!(
440                    "Link is malformed, there is no href. URL: '{}', LINK: '{}'",
441                    self.url, link.name
442                ))
443            })
444        }?;
445
446        let base_url = self.url.parse::<Url>()?;
447        let joined_url = base_url.join(&link_url)?;
448        self.fetch(joined_url.path().into()).await
449    }
450    pub async fn delete_url(
451        self,
452        link: &Link,
453        template_values: &HashMap<String, String>,
454    ) -> Result<Value, PactBrokerError> {
455        debug!(
456            "fetch_url(link={:?}, template_values={:?})",
457            link, template_values
458        );
459
460        let link_url = if link.templated {
461            debug!("Link URL is templated");
462            self.clone().parse_link_url(&link, &template_values)
463        } else {
464            link.href.clone().ok_or_else(|| {
465                PactBrokerError::LinkError(format!(
466                    "Link is malformed, there is no href. URL: '{}', LINK: '{}'",
467                    self.url, link.name
468                ))
469            })
470        }?;
471
472        let base_url = self.url.parse::<Url>()?;
473        debug!("base_url: {}", base_url);
474        debug!("link_url: {}", link_url);
475        let joined_url = base_url.join(&link_url)?;
476        debug!("joined_url: {}", joined_url);
477        self.delete(joined_url.path().into()).await
478    }
479
480    pub async fn fetch(self, path: &str) -> Result<Value, PactBrokerError> {
481        info!("Fetching path '{}' from pact broker", path);
482
483        let broker_url = self.url.parse::<Url>()?;
484        let context_path = broker_url.path();
485        let url = if context_path.is_empty().not()
486            && context_path != "/"
487            && path.starts_with(context_path)
488        {
489            let mut base_url = broker_url.clone();
490            base_url.set_path("/");
491            base_url.join(path)?
492        } else {
493            broker_url.join(path)?
494        };
495
496        let mut request_builder = match self.auth {
497            Some(ref auth) => match auth {
498                HttpAuth::User(username, password) => {
499                    self.client.get(url).basic_auth(username, password.clone())
500                }
501                HttpAuth::Token(token) => self.client.get(url).bearer_auth(token),
502                _ => self.client.get(url),
503            },
504            None => self.client.get(url),
505        }
506        .header("accept", "application/hal+json, application/json");
507
508        // Apply custom headers if present
509        request_builder = self.apply_custom_headers(request_builder);
510
511        let response = utils::with_retries(self.retries, request_builder)
512            .await
513            .map_err(|err| {
514                PactBrokerError::IoError(format!(
515                    "Failed to access pact broker path '{}' - {}. URL: '{}'",
516                    &path, err, &self.url,
517                ))
518            })?;
519
520        self.parse_broker_response(path.to_string(), response).await
521    }
522
523    pub async fn delete(self, path: &str) -> Result<Value, PactBrokerError> {
524        info!("Deleting path '{}' from pact broker", path);
525
526        let broker_url = self.url.parse::<Url>()?;
527        let context_path = broker_url.path();
528        let url = if context_path.is_empty().not()
529            && context_path != "/"
530            && path.starts_with(context_path)
531        {
532            let mut base_url = broker_url.clone();
533            base_url.set_path("/");
534            base_url.join(path)?
535        } else {
536            broker_url.join(path)?
537        };
538
539        let request_builder = match self.auth {
540            Some(ref auth) => match auth {
541                HttpAuth::User(username, password) => self
542                    .client
543                    .delete(url)
544                    .basic_auth(username, password.clone()),
545                HttpAuth::Token(token) => self.client.delete(url).bearer_auth(token),
546                _ => self.client.delete(url),
547            },
548            None => self.client.delete(url),
549        }
550        .header("Accept", "application/hal+json");
551
552        let response = utils::with_retries(self.retries, request_builder)
553            .await
554            .map_err(|err| {
555                PactBrokerError::IoError(format!(
556                    "Failed to delete pact broker path '{}' - {}. URL: '{}'",
557                    &path, err, &self.url,
558                ))
559            })?;
560
561        self.parse_broker_response(path.to_string(), response).await
562    }
563
564    async fn parse_broker_response(
565        &self,
566        path: String,
567        response: reqwest::Response,
568    ) -> Result<Value, PactBrokerError> {
569        let is_json_content_type = json_content_type(&response);
570        let content_type = content_type(&response);
571        let status_code = response.status();
572
573        if status_code.is_success() {
574            if is_json_content_type {
575                response.json::<Value>()
576            .await
577            .map_err(|err| PactBrokerError::ContentError(
578              format!("Did not get a valid HAL response body from pact broker path '{}' - {}. URL: '{}'",
579                      path, err, self.url)
580            ))
581            } else if status_code.as_u16() == 204 {
582                Ok(json!({}))
583            } else {
584                debug!("Request from broker was a success, but the response body was not JSON");
585                Err(PactBrokerError::ContentError(format!(
586                    "Did not get a valid HAL response body from pact broker path '{}', content type is '{}'. URL: '{}'",
587                    path, content_type, self.url
588                )))
589            }
590        } else if status_code.as_u16() == 404 {
591            Err(PactBrokerError::NotFound(format!(
592                "Request to pact broker path '{}' failed: {}. URL: '{}'",
593                path, status_code, self.url
594            )))
595        } else {
596            // Handle any error status code (400, 422, 409, etc.)
597            let body = response.bytes().await.map_err(|_| {
598                PactBrokerError::IoError(format!(
599                    "Failed to download response body for path '{}'. URL: '{}'",
600                    &path, self.url
601                ))
602            })?;
603
604            if is_json_content_type {
605                match serde_json::from_slice::<Value>(&body) {
606                    Ok(json_body) => {
607                        if json_body.get("errors").is_some() || json_body.get("notices").is_some() {
608                            Err(handle_validation_errors(json_body))
609                        } else {
610                            Err(PactBrokerError::IoError(format!(
611                                "Request to pact broker path '{}' failed: {}. Response: {}. URL: '{}'",
612                                path, status_code, json_body, self.url
613                            )))
614                        }
615                    }
616                    Err(_) => {
617                        let body_text = from_utf8(&body)
618                            .map(|b| b.to_string())
619                            .unwrap_or_else(|err| format!("could not read body: {}", err));
620                        error!(
621                            "Request to pact broker path '{}' failed: {}",
622                            path, body_text
623                        );
624                        Err(PactBrokerError::IoError(format!(
625                            "Request to pact broker path '{}' failed: {}. URL: '{}'",
626                            path, status_code, self.url
627                        )))
628                    }
629                }
630            } else {
631                let body_text = from_utf8(&body)
632                    .map(|b| b.to_string())
633                    .unwrap_or_else(|err| format!("could not read body: {}", err));
634                error!(
635                    "Request to pact broker path '{}' failed: {}",
636                    path, body_text
637                );
638                Err(PactBrokerError::IoError(format!(
639                    "Request to pact broker path '{}' failed: {}. URL: '{}'",
640                    path, status_code, self.url
641                )))
642            }
643        }
644    }
645
646    fn parse_link_url(
647        &self,
648        link: &Link,
649        values: &HashMap<String, String>,
650    ) -> Result<String, PactBrokerError> {
651        match link.href {
652            Some(ref href) => {
653                debug!("templated URL = {}", href);
654                let re = Regex::new(r"\{(\w+)}").unwrap();
655                let final_url = re.replace_all(href, |caps: &Captures| {
656                    let lookup = caps.get(1).unwrap().as_str();
657                    trace!("Looking up value for key '{}'", lookup);
658                    match values.get(lookup) {
659                        Some(val) => urlencoding::encode(val.as_str()).to_string(),
660                        None => {
661                            warn!(
662                                "No value was found for key '{}', mapped values are {:?}",
663                                lookup, values
664                            );
665                            format!("{{{}}}", lookup)
666                        }
667                    }
668                });
669                debug!("final URL = {}", final_url);
670                Ok(final_url.to_string())
671            }
672            None => Err(PactBrokerError::LinkError(format!(
673                "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{}'",
674                self.url, link.name
675            ))),
676        }
677    }
678
679    /// Iterate over all the links by name
680    pub fn iter_links(&self, link: &str) -> Result<Vec<Link>, PactBrokerError> {
681        match self.path_info {
682      None => Err(PactBrokerError::LinkError(format!("No previous resource has been fetched from the pact broker. URL: '{}', LINK: '{}'",
683        self.url, link))),
684      Some(ref json) => match json.get("_links") {
685        Some(json) => match json.get(&link) {
686          Some(link_data) => link_data.as_array()
687              .map(|link_data| link_data.iter().map(|link_json| match link_json {
688                Value::Object(data) => Link::from_json(&link, data),
689                Value::String(s) => Link { name: link.to_string(), href: Some(s.clone()), templated: false, title: None },
690                _ => Link { name: link.to_string(), href: Some(link_json.to_string()), templated: false, title: None }
691              }).collect())
692              .ok_or_else(|| PactBrokerError::LinkError(format!("Link is malformed, expected an object but got {}. URL: '{}', LINK: '{}'",
693                  link_data, self.url, link))),
694          None => Err(PactBrokerError::LinkError(format!("Link '{}' was not found in the response, only the following links where found: {:?}. URL: '{}', LINK: '{}'",
695            link, json.as_object().unwrap_or(&json!({}).as_object().unwrap()).keys().join(", "), self.url, link)))
696        },
697        None => Err(PactBrokerError::LinkError(format!("Expected a HAL+JSON response from the pact broker, but got a response with no '_links'. URL: '{}', LINK: '{}'",
698          self.url, link)))
699      }
700    }
701    }
702
703    pub async fn post_json(
704        &self,
705        url: &str,
706        body: &str,
707        headers: Option<HashMap<String, String>>,
708    ) -> Result<serde_json::Value, PactBrokerError> {
709        trace!("post_json(url='{}', body='{}')", url, body);
710
711        self.send_document(url, body, Method::POST, headers).await
712    }
713
714    pub async fn put_json(
715        &self,
716        url: &str,
717        body: &str,
718        headers: Option<HashMap<String, String>>,
719    ) -> Result<serde_json::Value, PactBrokerError> {
720        trace!("put_json(url='{}', body='{}')", url, body);
721
722        self.send_document(url, body, Method::PUT, headers).await
723    }
724    pub async fn patch_json(
725        &self,
726        url: &str,
727        body: &str,
728        headers: Option<HashMap<String, String>>,
729    ) -> Result<serde_json::Value, PactBrokerError> {
730        trace!("put_json(url='{}', body='{}')", url, body);
731
732        self.send_document(url, body, Method::PATCH, headers).await
733    }
734
735    async fn send_document(
736        &self,
737        url: &str,
738        body: &str,
739        method: Method,
740        headers: Option<HashMap<String, String>>,
741    ) -> Result<Value, PactBrokerError> {
742        let method_type = method.clone();
743        debug!("Sending JSON to {} using {}: {}", url, method, body);
744
745        let base_url = &self.url.parse::<Url>()?;
746        let url = if url.starts_with("/") {
747            base_url.join(url)?
748        } else {
749            let url = url.parse::<Url>()?;
750            base_url.join(&url.path())?
751        };
752
753        let request_builder = match self.auth {
754            Some(ref auth) => match auth {
755                HttpAuth::User(username, password) => self
756                    .client
757                    .request(method, url.clone())
758                    .basic_auth(username, password.clone()),
759                HttpAuth::Token(token) => {
760                    self.client.request(method, url.clone()).bearer_auth(token)
761                }
762                _ => self.client.request(method, url.clone()),
763            },
764            None => self.client.request(method, url.clone()),
765        }
766        .header("Accept", "application/hal+json")
767        .body(body.to_string());
768
769        // Add any additional headers if provided
770
771        let request_builder = if let Some(ref headers) = headers {
772            headers
773                .iter()
774                .fold(request_builder, |builder, (key, value)| {
775                    builder.header(key.as_str(), value.as_str())
776                })
777        } else {
778            request_builder
779        };
780
781        let request_builder = if method_type == Method::PATCH {
782            request_builder.header("Content-Type", "application/merge-patch+json")
783        } else {
784            request_builder.header("Content-Type", "application/json")
785        };
786        let response = with_retries(self.retries, request_builder).await;
787        match response {
788            Ok(res) => {
789                self.parse_broker_response(url.path().to_string(), res)
790                    .await
791            }
792            Err(err) => Err(PactBrokerError::IoError(format!(
793                "Failed to send JSON to the pact broker URL '{}' - IoError {}",
794                url, err
795            ))),
796        }
797    }
798
799    fn with_doc_context(self, doc_attributes: &[Link]) -> Result<HALClient, PactBrokerError> {
800        let links: serde_json::Map<String, serde_json::Value> = doc_attributes
801            .iter()
802            .map(|link| (link.name.clone(), link.as_json()))
803            .collect();
804        let links_json = json!({
805          "_links": json!(links)
806        });
807        Ok(self.update_path_info(links_json))
808    }
809}
810
811fn handle_validation_errors(body: Value) -> PactBrokerError {
812    match &body {
813        Value::Object(attrs) => {
814            // Extract notices if present
815            let notices: Vec<Notice> = attrs
816                .get("notices")
817                .and_then(|n| n.as_array())
818                .map(|notices_array| {
819                    notices_array
820                        .iter()
821                        .filter_map(|notice| serde_json::from_value::<Notice>(notice.clone()).ok())
822                        .collect()
823                })
824                .unwrap_or_default();
825
826            if let Some(errors) = attrs.get("errors") {
827                let error_messages = match errors {
828                    Value::Array(values) => values.iter().map(|v| json_to_string(v)).collect(),
829                    Value::Object(errors) => errors
830                        .iter()
831                        .map(|(field, errors)| match errors {
832                            Value::String(error) => format!("{}: {}", field, error),
833                            Value::Array(errors) => format!(
834                                "{}: {}",
835                                field,
836                                errors.iter().map(|err| json_to_string(err)).join(", ")
837                            ),
838                            _ => format!("{}: {}", field, errors),
839                        })
840                        .collect(),
841                    Value::String(s) => vec![s.clone()],
842                    _ => vec![errors.to_string()],
843                };
844
845                if !notices.is_empty() {
846                    PactBrokerError::ValidationErrorWithNotices(error_messages, notices)
847                } else {
848                    PactBrokerError::ValidationError(error_messages)
849                }
850            } else if !notices.is_empty() {
851                // Even if there are no explicit errors, notices might contain error information
852                let notice_messages = notices.iter().map(|n| n.text.clone()).collect();
853                PactBrokerError::ValidationErrorWithNotices(notice_messages, notices)
854            } else {
855                PactBrokerError::ValidationError(vec![body.to_string()])
856            }
857        }
858        Value::String(s) => PactBrokerError::ValidationError(vec![s.clone()]),
859        _ => PactBrokerError::ValidationError(vec![body.to_string()]),
860    }
861}
862
863impl HALClient {
864    pub fn setup(url: &str, auth: Option<HttpAuth>, ssl_options: SslOptions) -> HALClient {
865        let mut builder = reqwest::Client::builder().user_agent(format!(
866            "{}/{}",
867            env!("CARGO_PKG_NAME"),
868            env!("CARGO_PKG_VERSION")
869        ));
870
871        debug!("Using ssl_options: {:?}", ssl_options);
872        if let Some(ref path) = ssl_options.ssl_cert_path {
873            if let Ok(cert_bytes) = std::fs::read(path) {
874                if let Ok(cert) = reqwest::Certificate::from_pem_bundle(&cert_bytes) {
875                    debug!("Adding SSL certificate from path: {}", path);
876                    for c in cert {
877                        builder = builder.add_root_certificate(c.clone());
878                    }
879                }
880            } else {
881                debug!(
882                    "Could not read SSL certificate from provided path: {}",
883                    path
884                );
885            }
886        }
887        if ssl_options.skip_ssl {
888            builder = builder.danger_accept_invalid_certs(true);
889            debug!("Skipping SSL certificate validation");
890        }
891        if !ssl_options.use_root_trust_store {
892            builder = builder.tls_built_in_root_certs(false);
893            debug!("Disabling root trust store for SSL");
894        }
895
896        let built_client = builder.build().unwrap();
897        let client = ClientBuilder::new(built_client)
898            .with(TracingMiddleware::default())
899            .with(OtelPropagatorMiddleware)
900            .build();
901
902        HALClient {
903            client,
904            url: url.to_string(),
905            path_info: None,
906            auth,
907            custom_headers: None,
908            retries: 3,
909            ssl_options,
910        }
911    }
912}
913
914pub fn links_from_json(json: &Value) -> Vec<Link> {
915    match json.get("_links") {
916        Some(json) => match json {
917            Value::Object(v) => v
918                .iter()
919                .map(|(link, json)| match json {
920                    Value::Object(attr) => Link::from_json(link, attr),
921                    _ => Link {
922                        name: link.clone(),
923                        ..Link::default()
924                    },
925                })
926                .collect(),
927            _ => vec![],
928        },
929        None => vec![],
930    }
931}
932
933/// Fetches the pacts from the broker that match the provider name
934pub async fn fetch_pacts_from_broker(
935    broker_url: &str,
936    provider_name: &str,
937    auth: Option<HttpAuth>,
938    ssl_options: SslOptions,
939    custom_headers: Option<CustomHeaders>,
940) -> anyhow::Result<
941    Vec<
942        anyhow::Result<(
943            Box<dyn Pact + Send + Sync + RefUnwindSafe>,
944            Option<PactVerificationContext>,
945            Vec<Link>,
946        )>,
947    >,
948> {
949    trace!(
950        "fetch_pacts_from_broker(broker_url='{}', provider_name='{}', auth={})",
951        broker_url,
952        provider_name,
953        auth.clone().unwrap_or_default()
954    );
955
956    let mut hal_client = HALClient::with_url(broker_url, auth, ssl_options, custom_headers);
957    let template_values = hashmap! { "provider".to_string() => provider_name.to_string() };
958
959    hal_client = hal_client
960        .navigate("pb:latest-provider-pacts", &template_values)
961        .await
962        .map_err(move |err| match err {
963            PactBrokerError::NotFound(_) => PactBrokerError::NotFound(format!(
964                "No pacts for provider '{}' where found in the pact broker. URL: '{}'",
965                provider_name, broker_url
966            )),
967            _ => err,
968        })?;
969
970    let pact_links = hal_client.clone().iter_links("pacts")?;
971
972    let results: Vec<_> = futures::stream::iter(pact_links)
973        .map(|ref pact_link| {
974          match pact_link.href {
975            Some(_) => Ok((hal_client.clone(), pact_link.clone())),
976            None => Err(
977              PactBrokerError::LinkError(
978                format!(
979                  "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{:?}'",
980                  &hal_client.url,
981                  pact_link
982                )
983              )
984            )
985          }
986        })
987        .and_then(|(hal_client, pact_link)| async {
988          let pact_json = hal_client.fetch_url(
989            &pact_link.clone(),
990            &template_values.clone()
991          ).await?;
992          Ok((pact_link, pact_json))
993        })
994        .map(|result| {
995          match result {
996            Ok((pact_link, pact_json)) => {
997              let href = pact_link.href.unwrap_or_default();
998              let links = links_from_json(&pact_json);
999              load_pact_from_json(href.as_str(), &pact_json)
1000                .map(|pact| (pact, None, links))
1001            },
1002            Err(err) => Err(err.into())
1003          }
1004        })
1005        .into_stream()
1006        .collect()
1007        .await;
1008
1009    Ok(results)
1010}
1011
1012/// Fetch Pacts from the broker using the "provider-pacts-for-verification" endpoint
1013pub async fn fetch_pacts_dynamically_from_broker(
1014    broker_url: &str,
1015    provider_name: String,
1016    pending: bool,
1017    include_wip_pacts_since: Option<String>,
1018    provider_tags: Vec<String>,
1019    provider_branch: Option<String>,
1020    consumer_version_selectors: Vec<ConsumerVersionSelector>,
1021    auth: Option<HttpAuth>,
1022    ssl_options: SslOptions,
1023    headers: Option<HashMap<String, String>>,
1024    custom_headers: Option<CustomHeaders>,
1025) -> anyhow::Result<
1026    Vec<
1027        Result<
1028            (
1029                Box<dyn Pact + Send + Sync + RefUnwindSafe>,
1030                Option<PactVerificationContext>,
1031                Vec<Link>,
1032            ),
1033            PactBrokerError,
1034        >,
1035    >,
1036> {
1037    trace!(
1038        "fetch_pacts_dynamically_from_broker(broker_url='{}', provider_name='{}', pending={}, \
1039    include_wip_pacts_since={:?}, provider_tags: {:?}, consumer_version_selectors: {:?}, auth={})",
1040        broker_url,
1041        provider_name,
1042        pending,
1043        include_wip_pacts_since,
1044        provider_tags,
1045        consumer_version_selectors,
1046        auth.clone().unwrap_or_default()
1047    );
1048
1049    let mut hal_client = HALClient::with_url(broker_url, auth, ssl_options, custom_headers);
1050    let template_values = hashmap! { "provider".to_string() => provider_name.clone() };
1051
1052    hal_client = hal_client
1053        .navigate("pb:provider-pacts-for-verification", &template_values)
1054        .await
1055        .map_err(move |err| match err {
1056            PactBrokerError::NotFound(_) => PactBrokerError::NotFound(format!(
1057                "No pacts for provider '{}' were found in the pact broker. URL: '{}'",
1058                provider_name.clone(),
1059                broker_url
1060            )),
1061            _ => err,
1062        })?;
1063
1064    // Construct the Pacts for verification payload
1065    let pacts_for_verification = PactsForVerificationRequest {
1066        provider_version_tags: provider_tags,
1067        provider_version_branch: provider_branch,
1068        include_wip_pacts_since,
1069        consumer_version_selectors,
1070        include_pending_status: pending,
1071    };
1072    let request_body = serde_json::to_string(&pacts_for_verification).unwrap();
1073
1074    // Post the verification request
1075    let response = match hal_client.find_link("self") {
1076        Ok(link) => {
1077            let link = hal_client.clone().parse_link_url(&link, &hashmap! {})?;
1078            match hal_client
1079                .clone()
1080                .post_json(link.as_str(), request_body.as_str(), headers)
1081                .await
1082            {
1083                Ok(res) => Some(res),
1084                Err(err) => {
1085                    info!("error response for pacts for verification: {} ", err);
1086                    return Err(anyhow!(err));
1087                }
1088            }
1089        }
1090        Err(e) => return Err(anyhow!(e)),
1091    };
1092
1093    // Find all of the Pact links
1094    let pact_links = match response {
1095        Some(v) => {
1096            let pfv: PactsForVerificationResponse = serde_json::from_value(v)
1097                .map_err(|err| {
1098                    trace!(
1099                        "Failed to deserialise PactsForVerificationResponse: {}",
1100                        err
1101                    );
1102                    err
1103                })
1104                .unwrap_or(PactsForVerificationResponse {
1105                    embedded: PactsForVerificationBody { pacts: vec![] },
1106                });
1107            trace!(?pfv, "got pacts for verification response");
1108
1109            if pfv.embedded.pacts.len() == 0 {
1110                return Err(anyhow!(PactBrokerError::NotFound(format!(
1111                    "No pacts were found for this provider"
1112                ))));
1113            };
1114
1115            let links: Result<Vec<(Link, PactVerificationContext)>, PactBrokerError> = pfv.embedded.pacts.iter().map(| p| {
1116          match p.links.get("self") {
1117            Some(l) => Ok((l.clone(), p.into())),
1118            None => Err(
1119              PactBrokerError::LinkError(
1120                format!(
1121                  "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', PATH: '{:?}'",
1122                  &hal_client.url,
1123                  &p.links,
1124                )
1125              )
1126            )
1127          }
1128        }).collect();
1129
1130            links
1131        }
1132        None => Err(PactBrokerError::NotFound(format!(
1133            "No pacts were found for this provider"
1134        ))),
1135    }?;
1136
1137    let results: Vec<_> = futures::stream::iter(pact_links)
1138      .map(|(ref pact_link, ref context)| {
1139        match pact_link.href {
1140          Some(_) => Ok((hal_client.clone(), pact_link.clone(), context.clone())),
1141          None => Err(
1142            PactBrokerError::LinkError(
1143              format!(
1144                "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{:?}'",
1145                &hal_client.url,
1146                pact_link
1147              )
1148            )
1149          )
1150        }
1151      })
1152      .and_then(|(hal_client, pact_link, context)| async {
1153        let pact_json = hal_client.fetch_url(
1154          &pact_link.clone(),
1155          &template_values.clone()
1156        ).await?;
1157        Ok((pact_link, pact_json, context))
1158      })
1159      .map(|result| {
1160        match result {
1161          Ok((pact_link, pact_json, context)) => {
1162            let href = pact_link.href.unwrap_or_default();
1163            let links = links_from_json(&pact_json);
1164            load_pact_from_json(href.as_str(), &pact_json)
1165              .map(|pact| (pact, Some(context), links))
1166              .map_err(|err| PactBrokerError::ContentError(format!("{}", err)))
1167          },
1168          Err(err) => Err(err)
1169        }
1170      })
1171      .into_stream()
1172      .collect()
1173      .await;
1174
1175    Ok(results)
1176}
1177
1178/// Fetch the Pact from the given URL, using any required authentication. This will use a GET
1179/// request to the given URL and parse the result into a Pact model. It will also look for any HAL
1180/// links in the response, returning those if found.
1181pub async fn fetch_pact_from_url(
1182    url: &str,
1183    auth: &Option<HttpAuth>,
1184) -> anyhow::Result<(Box<dyn Pact + Send + Sync + RefUnwindSafe>, Vec<Link>)> {
1185    let url = url.to_string();
1186    let auth = auth.clone();
1187    let (url, pact_json) =
1188        tokio::task::spawn_blocking(move || http_utils::fetch_json_from_url(&url, &auth)).await??;
1189    let pact = load_pact_from_json(&url, &pact_json)?;
1190    let links = links_from_json(&pact_json);
1191    Ok((pact, links))
1192}
1193
1194async fn publish_provider_tags(
1195    hal_client: &HALClient,
1196    links: &[Link],
1197    provider_tags: Vec<String>,
1198    version: &str,
1199    headers: Option<HashMap<String, String>>,
1200) -> Result<(), PactBrokerError> {
1201    let hal_client = hal_client
1202        .clone()
1203        .with_doc_context(links)?
1204        .navigate("pb:provider", &hashmap! {})
1205        .await?;
1206    match hal_client.find_link("pb:version-tag") {
1207        Ok(link) => {
1208            for tag in &provider_tags {
1209                let template_values = hashmap! {
1210                  "version".to_string() => version.to_string(),
1211                  "tag".to_string() => tag.clone()
1212                };
1213                match hal_client
1214                    .clone()
1215                    .put_json(
1216                        hal_client
1217                            .clone()
1218                            .parse_link_url(&link, &template_values)?
1219                            .as_str(),
1220                        "{}",
1221                        headers.clone(),
1222                    )
1223                    .await
1224                {
1225                    Ok(_) => debug!("Pushed tag {} for provider version {}", tag, version),
1226                    Err(err) => {
1227                        error!(
1228                            "Failed to push tag {} for provider version {}",
1229                            tag, version
1230                        );
1231                        return Err(err);
1232                    }
1233                }
1234            }
1235            Ok(())
1236        }
1237        Err(_) => Err(PactBrokerError::LinkError(
1238            "Can't publish provider tags as there is no 'pb:version-tag' link".to_string(),
1239        )),
1240    }
1241}
1242
1243async fn publish_provider_branch(
1244    hal_client: &HALClient,
1245    links: &[Link],
1246    branch: &str,
1247    version: &str,
1248    headers: Option<HashMap<String, String>>,
1249) -> Result<(), PactBrokerError> {
1250    let hal_client = hal_client
1251        .clone()
1252        .with_doc_context(links)?
1253        .navigate("pb:provider", &hashmap! {})
1254        .await?;
1255
1256    match hal_client.find_link("pb:branch-version") {
1257    Ok(link) => {
1258      let template_values = hashmap! {
1259        "branch".to_string() => branch.to_string(),
1260        "version".to_string() => version.to_string(),
1261      };
1262      match hal_client.clone().put_json(hal_client.clone().parse_link_url(&link, &template_values)?.as_str(), "{}",headers).await {
1263        Ok(_) => debug!("Pushed branch {} for provider version {}", branch, version),
1264        Err(err) => {
1265          error!("Failed to push branch {} for provider version {}", branch, version);
1266          return Err(err);
1267        }
1268      }
1269      Ok(())
1270    },
1271    Err(_) => Err(PactBrokerError::LinkError("Can't publish provider branch as there is no 'pb:branch-version' link. Please ugrade to Pact Broker version 2.86.0 or later for branch support".to_string()))
1272  }
1273}
1274
1275#[skip_serializing_none]
1276#[derive(Serialize, Deserialize, Debug, Clone)]
1277#[serde(rename_all = "camelCase")]
1278/// Structure to represent a HAL link
1279pub struct ConsumerVersionSelector {
1280    /// Application name to filter the results on
1281    pub consumer: Option<String>,
1282    /// Tag
1283    pub tag: Option<String>,
1284    /// Fallback tag if Tag doesn't exist
1285    pub fallback_tag: Option<String>,
1286    /// Only select the latest (if false, this selects all pacts for a tag)
1287    pub latest: Option<bool>,
1288    /// Applications that have been deployed or released
1289    pub deployed_or_released: Option<bool>,
1290    /// Applications that have been deployed
1291    pub deployed: Option<bool>,
1292    /// Applications that have been released
1293    pub released: Option<bool>,
1294    /// Applications in a given environment
1295    pub environment: Option<String>,
1296    /// Applications with the default branch set in the broker
1297    pub main_branch: Option<bool>,
1298    /// Applications with the given branch
1299    pub branch: Option<String>,
1300    /// Applications that match the the provider version branch sent during verification
1301    pub matching_branch: Option<bool>,
1302}
1303
1304#[derive(Serialize, Deserialize, Debug, Clone)]
1305#[serde(rename_all = "camelCase")]
1306struct PactsForVerificationResponse {
1307    #[serde(rename(deserialize = "_embedded"))]
1308    pub embedded: PactsForVerificationBody,
1309}
1310
1311#[derive(Serialize, Deserialize, Debug, Clone)]
1312#[serde(rename_all = "camelCase")]
1313struct PactsForVerificationBody {
1314    pub pacts: Vec<PactForVerification>,
1315}
1316
1317#[derive(Serialize, Deserialize, Debug, Clone)]
1318#[serde(rename_all = "camelCase")]
1319struct PactForVerification {
1320    pub short_description: String,
1321    #[serde(rename(deserialize = "_links"))]
1322    pub links: HashMap<String, Link>,
1323    pub verification_properties: Option<PactVerificationProperties>,
1324}
1325
1326#[skip_serializing_none]
1327#[derive(Serialize, Deserialize, Debug, Clone)]
1328#[serde(rename_all = "camelCase")]
1329/// Request to send to determine the pacts to verify
1330pub struct PactsForVerificationRequest {
1331    /// Provider tags to use for determining pending pacts (if enabled)
1332    #[serde(skip_serializing_if = "Vec::is_empty")]
1333    pub provider_version_tags: Vec<String>,
1334    /// Enable pending pacts feature
1335    pub include_pending_status: bool,
1336    /// Find WIP pacts after given date
1337    pub include_wip_pacts_since: Option<String>,
1338    /// Detailed pact selection criteria , see https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/
1339    pub consumer_version_selectors: Vec<ConsumerVersionSelector>,
1340    /// Current provider version branch if used (instead of tags)
1341    pub provider_version_branch: Option<String>,
1342}
1343
1344#[skip_serializing_none]
1345#[derive(Serialize, Deserialize, Debug, Clone)]
1346#[serde(rename_all = "camelCase")]
1347/// Provides the context on why a Pact was included
1348pub struct PactVerificationContext {
1349    /// Description
1350    pub short_description: String,
1351    /// Properties
1352    pub verification_properties: PactVerificationProperties,
1353}
1354
1355impl From<&PactForVerification> for PactVerificationContext {
1356    fn from(value: &PactForVerification) -> Self {
1357        PactVerificationContext {
1358            short_description: value.short_description.clone(),
1359            verification_properties: value.verification_properties.clone().unwrap_or_default(),
1360        }
1361    }
1362}
1363
1364#[skip_serializing_none]
1365#[derive(Serialize, Deserialize, Debug, Clone, Default)]
1366#[serde(rename_all = "camelCase")]
1367/// Properties associated with the verification context
1368pub struct PactVerificationProperties {
1369    #[serde(default)]
1370    /// If the Pact is pending
1371    pub pending: bool,
1372    /// Notices provided by the Pact Broker
1373    pub notices: Vec<HashMap<String, String>>,
1374}
1375
1376#[cfg(test)]
1377mod hal_client_custom_headers_tests {
1378    use super::*;
1379    use crate::cli::pact_broker::main::types::SslOptions;
1380    use std::collections::HashMap;
1381
1382    fn create_test_custom_headers() -> CustomHeaders {
1383        let mut headers = HashMap::new();
1384        headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
1385        headers.insert("X-API-Key".to_string(), "secret-key".to_string());
1386        CustomHeaders { headers }
1387    }
1388
1389    fn create_cloudflare_custom_headers() -> CustomHeaders {
1390        let mut headers = HashMap::new();
1391        headers.insert(
1392            "CF-Access-Client-Id".to_string(),
1393            "client-id-123".to_string(),
1394        );
1395        headers.insert(
1396            "CF-Access-Client-Secret".to_string(),
1397            "secret-456".to_string(),
1398        );
1399        CustomHeaders { headers }
1400    }
1401
1402    #[test]
1403    fn test_hal_client_with_custom_headers() {
1404        let custom_headers = Some(create_test_custom_headers());
1405        let ssl_options = SslOptions::default();
1406
1407        let client = HALClient::with_url(
1408            "https://test.example.com",
1409            None,
1410            ssl_options,
1411            custom_headers.clone(),
1412        );
1413
1414        assert_eq!(client.url, "https://test.example.com");
1415        assert!(client.custom_headers.is_some());
1416
1417        let headers = client.custom_headers.unwrap();
1418        assert_eq!(headers.headers.len(), 2);
1419        assert_eq!(
1420            headers.headers.get("Authorization"),
1421            Some(&"Bearer test-token".to_string())
1422        );
1423        assert_eq!(
1424            headers.headers.get("X-API-Key"),
1425            Some(&"secret-key".to_string())
1426        );
1427    }
1428
1429    #[test]
1430    fn test_hal_client_with_cloudflare_headers() {
1431        let custom_headers = Some(create_cloudflare_custom_headers());
1432        let ssl_options = SslOptions::default();
1433
1434        let client = HALClient::with_url(
1435            "https://pact-broker.example.com",
1436            None,
1437            ssl_options,
1438            custom_headers.clone(),
1439        );
1440
1441        assert!(client.custom_headers.is_some());
1442
1443        let headers = client.custom_headers.unwrap();
1444        assert_eq!(headers.headers.len(), 2);
1445        assert_eq!(
1446            headers.headers.get("CF-Access-Client-Id"),
1447            Some(&"client-id-123".to_string())
1448        );
1449        assert_eq!(
1450            headers.headers.get("CF-Access-Client-Secret"),
1451            Some(&"secret-456".to_string())
1452        );
1453    }
1454
1455    #[test]
1456    fn test_hal_client_without_custom_headers() {
1457        let ssl_options = SslOptions::default();
1458
1459        let client = HALClient::with_url("https://test.example.com", None, ssl_options, None);
1460
1461        assert!(client.custom_headers.is_none());
1462    }
1463
1464    #[test]
1465    fn test_hal_client_with_auth_and_custom_headers() {
1466        let auth = Some(HttpAuth::Token("bearer-token".to_string()));
1467        let custom_headers = Some(create_test_custom_headers());
1468        let ssl_options = SslOptions::default();
1469
1470        let client = HALClient::with_url(
1471            "https://test.example.com",
1472            auth.clone(),
1473            ssl_options,
1474            custom_headers,
1475        );
1476
1477        assert!(client.auth.is_some());
1478        assert!(client.custom_headers.is_some());
1479
1480        if let Some(HttpAuth::Token(token)) = client.auth {
1481            assert_eq!(token, "bearer-token");
1482        }
1483    }
1484
1485    #[test]
1486    fn test_apply_custom_headers_with_mock_request() {
1487        use reqwest::Client;
1488        use reqwest_middleware::ClientBuilder;
1489
1490        let custom_headers = Some(create_test_custom_headers());
1491        let ssl_options = SslOptions::default();
1492
1493        let client = HALClient::with_url(
1494            "https://test.example.com",
1495            None,
1496            ssl_options,
1497            custom_headers,
1498        );
1499
1500        // Create a mock request builder to test header application
1501        let reqwest_client = Client::new();
1502        let middleware_client = ClientBuilder::new(reqwest_client).build();
1503        let request_builder = middleware_client.get("https://test.example.com/test");
1504
1505        // Apply custom headers
1506        let modified_builder = client.apply_custom_headers(request_builder);
1507
1508        // Build the request to inspect headers
1509        let request = modified_builder.build().unwrap();
1510
1511        // Check that custom headers were applied
1512        assert!(request.headers().contains_key("authorization"));
1513        assert!(request.headers().contains_key("x-api-key"));
1514
1515        assert_eq!(
1516            request
1517                .headers()
1518                .get("authorization")
1519                .unwrap()
1520                .to_str()
1521                .unwrap(),
1522            "Bearer test-token"
1523        );
1524        assert_eq!(
1525            request
1526                .headers()
1527                .get("x-api-key")
1528                .unwrap()
1529                .to_str()
1530                .unwrap(),
1531            "secret-key"
1532        );
1533    }
1534
1535    #[test]
1536    fn test_apply_custom_headers_without_headers() {
1537        use reqwest::Client;
1538        use reqwest_middleware::ClientBuilder;
1539
1540        let ssl_options = SslOptions::default();
1541
1542        let client = HALClient::with_url("https://test.example.com", None, ssl_options, None);
1543
1544        // Create a mock request builder
1545        let reqwest_client = Client::new();
1546        let middleware_client = ClientBuilder::new(reqwest_client).build();
1547        let request_builder = middleware_client.get("https://test.example.com/test");
1548
1549        // Apply custom headers (should be no-op)
1550        let modified_builder = client.apply_custom_headers(request_builder);
1551
1552        // Build the request to inspect headers
1553        let request = modified_builder.build().unwrap();
1554
1555        // Should not contain our test headers
1556        assert!(!request.headers().contains_key("authorization"));
1557        assert!(!request.headers().contains_key("x-api-key"));
1558    }
1559
1560    #[test]
1561    fn test_custom_headers_struct_creation() {
1562        let mut headers = HashMap::new();
1563        headers.insert("Test-Header".to_string(), "test-value".to_string());
1564
1565        let custom_headers = CustomHeaders { headers };
1566
1567        assert_eq!(custom_headers.headers.len(), 1);
1568        assert_eq!(
1569            custom_headers.headers.get("Test-Header"),
1570            Some(&"test-value".to_string())
1571        );
1572    }
1573
1574    #[test]
1575    fn test_custom_headers_empty() {
1576        let headers = HashMap::new();
1577        let custom_headers = CustomHeaders { headers };
1578
1579        assert_eq!(custom_headers.headers.len(), 0);
1580        assert!(custom_headers.headers.is_empty());
1581    }
1582
1583    #[test]
1584    fn test_custom_headers_case_sensitivity() {
1585        let mut headers = HashMap::new();
1586        headers.insert("content-type".to_string(), "application/json".to_string());
1587        headers.insert("Content-Type".to_string(), "text/plain".to_string());
1588
1589        let custom_headers = CustomHeaders { headers };
1590
1591        // Both should exist as separate entries (case sensitive keys)
1592        assert_eq!(custom_headers.headers.len(), 2);
1593        assert_eq!(
1594            custom_headers.headers.get("content-type"),
1595            Some(&"application/json".to_string())
1596        );
1597        assert_eq!(
1598            custom_headers.headers.get("Content-Type"),
1599            Some(&"text/plain".to_string())
1600        );
1601    }
1602}