Skip to main content

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.fetch("").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.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.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        trace!(%path, broker_url = %self.url, ">> fetch");
483        let url = self.resolve_path(path)?;
484            debug!("Final broker URL: {}", url);
485
486        let mut request_builder = match self.auth {
487            Some(ref auth) => match auth {
488                HttpAuth::User(username, password) => {
489                    self.client.get(url).basic_auth(username, password.clone())
490                }
491                HttpAuth::Token(token) => self.client.get(url).bearer_auth(token),
492                _ => self.client.get(url),
493            },
494            None => self.client.get(url),
495        }
496        .header("accept", "application/hal+json, application/json");
497
498        // Apply custom headers if present
499        request_builder = self.apply_custom_headers(request_builder);
500
501        let response = utils::with_retries(self.retries, request_builder)
502            .await
503            .map_err(|err| {
504                PactBrokerError::IoError(format!(
505                    "Failed to access pact broker path '{}' - {}. URL: '{}'",
506                    &path, err, &self.url,
507                ))
508            })?;
509
510        self.parse_broker_response(path.to_string(), response).await
511    }
512
513      fn resolve_path(&self, path: &str) -> Result<Url, PactBrokerError> {
514        let broker_url = self.url.parse::<Url>()?;
515        let context_path = broker_url.path();
516        let url = if path.is_empty() {
517        broker_url
518        } else if !context_path.is_empty() && context_path != "/" {
519        if path.starts_with(context_path) {
520            let mut base_url = broker_url.clone();
521            base_url.set_path("/");
522            base_url.join(path)?
523        } else if path.starts_with("/") {
524            let mut base_url = broker_url.clone();
525            base_url.set_path(path);
526            base_url
527        } else {
528            let mut base_url = broker_url.clone();
529            let mut cp = context_path.to_string();
530            cp.push('/');
531            base_url.set_path(cp.as_str());
532            base_url.join(path)?
533        }
534        } else {
535        broker_url.join(path)?
536        };
537        Ok(url)
538    }
539
540    pub async fn delete(self, path: &str) -> Result<Value, PactBrokerError> {
541        info!("Deleting path '{}' from pact broker", path);
542
543        let broker_url = self.url.parse::<Url>()?;
544        let context_path = broker_url.path();
545        let url = if context_path.is_empty().not()
546            && context_path != "/"
547            && path.starts_with(context_path)
548        {
549            let mut base_url = broker_url.clone();
550            base_url.set_path("/");
551            base_url.join(path)?
552        } else {
553            broker_url.join(path)?
554        };
555
556        let request_builder = match self.auth {
557            Some(ref auth) => match auth {
558                HttpAuth::User(username, password) => self
559                    .client
560                    .delete(url)
561                    .basic_auth(username, password.clone()),
562                HttpAuth::Token(token) => self.client.delete(url).bearer_auth(token),
563                _ => self.client.delete(url),
564            },
565            None => self.client.delete(url),
566        }
567        .header("Accept", "application/hal+json");
568
569        let response = utils::with_retries(self.retries, request_builder)
570            .await
571            .map_err(|err| {
572                PactBrokerError::IoError(format!(
573                    "Failed to delete pact broker path '{}' - {}. URL: '{}'",
574                    &path, err, &self.url,
575                ))
576            })?;
577
578        self.parse_broker_response(path.to_string(), response).await
579    }
580
581    async fn parse_broker_response(
582        &self,
583        path: String,
584        response: reqwest::Response,
585    ) -> Result<Value, PactBrokerError> {
586        let is_json_content_type = json_content_type(&response);
587        let content_type = content_type(&response);
588        let status_code = response.status();
589
590        if status_code.is_success() {
591            if is_json_content_type {
592                response.json::<Value>()
593            .await
594            .map_err(|err| PactBrokerError::ContentError(
595              format!("Did not get a valid HAL response body from pact broker path '{}' - {}. URL: '{}'",
596                      path, err, self.url)
597            ))
598            } else if status_code.as_u16() == 204 {
599                Ok(json!({}))
600            } else {
601                debug!("Request from broker was a success, but the response body was not JSON");
602                Err(PactBrokerError::ContentError(format!(
603                    "Did not get a valid HAL response body from pact broker path '{}', content type is '{}'. URL: '{}'",
604                    path, content_type, self.url
605                )))
606            }
607        } else if status_code.as_u16() == 404 {
608            Err(PactBrokerError::NotFound(format!(
609                "Request to pact broker path '{}' failed: {}. URL: '{}'",
610                path, status_code, self.url
611            )))
612        } else {
613            // Handle any error status code (400, 422, 409, etc.)
614            let body = response.bytes().await.map_err(|_| {
615                PactBrokerError::IoError(format!(
616                    "Failed to download response body for path '{}'. URL: '{}'",
617                    &path, self.url
618                ))
619            })?;
620
621            if is_json_content_type {
622                match serde_json::from_slice::<Value>(&body) {
623                    Ok(json_body) => {
624                        if json_body.get("errors").is_some() || json_body.get("notices").is_some() {
625                            Err(handle_validation_errors(json_body))
626                        } else {
627                            Err(PactBrokerError::IoError(format!(
628                                "Request to pact broker path '{}' failed: {}. Response: {}. URL: '{}'",
629                                path, status_code, json_body, self.url
630                            )))
631                        }
632                    }
633                    Err(_) => {
634                        let body_text = from_utf8(&body)
635                            .map(|b| b.to_string())
636                            .unwrap_or_else(|err| format!("could not read body: {}", err));
637                        error!(
638                            "Request to pact broker path '{}' failed: {}",
639                            path, body_text
640                        );
641                        Err(PactBrokerError::IoError(format!(
642                            "Request to pact broker path '{}' failed: {}. URL: '{}'",
643                            path, status_code, self.url
644                        )))
645                    }
646                }
647            } else {
648                let body_text = from_utf8(&body)
649                    .map(|b| b.to_string())
650                    .unwrap_or_else(|err| format!("could not read body: {}", err));
651                error!(
652                    "Request to pact broker path '{}' failed: {}",
653                    path, body_text
654                );
655                Err(PactBrokerError::IoError(format!(
656                    "Request to pact broker path '{}' failed: {}. URL: '{}'",
657                    path, status_code, self.url
658                )))
659            }
660        }
661    }
662
663    fn parse_link_url(
664        &self,
665        link: &Link,
666        values: &HashMap<String, String>,
667    ) -> Result<String, PactBrokerError> {
668        match link.href {
669            Some(ref href) => {
670                debug!("templated URL = {}", href);
671                let re = Regex::new(r"\{(\w+)}").unwrap();
672                let final_url = re.replace_all(href, |caps: &Captures| {
673                    let lookup = caps.get(1).unwrap().as_str();
674                    trace!("Looking up value for key '{}'", lookup);
675                    match values.get(lookup) {
676                        Some(val) => urlencoding::encode(val.as_str()).to_string(),
677                        None => {
678                            warn!(
679                                "No value was found for key '{}', mapped values are {:?}",
680                                lookup, values
681                            );
682                            format!("{{{}}}", lookup)
683                        }
684                    }
685                });
686                debug!("final URL = {}", final_url);
687                Ok(final_url.to_string())
688            }
689            None => Err(PactBrokerError::LinkError(format!(
690                "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{}'",
691                self.url, link.name
692            ))),
693        }
694    }
695
696    /// Iterate over all the links by name
697    pub fn iter_links(&self, link: &str) -> Result<Vec<Link>, PactBrokerError> {
698        match self.path_info {
699      None => Err(PactBrokerError::LinkError(format!("No previous resource has been fetched from the pact broker. URL: '{}', LINK: '{}'",
700        self.url, link))),
701      Some(ref json) => match json.get("_links") {
702        Some(json) => match json.get(&link) {
703          Some(link_data) => link_data.as_array()
704              .map(|link_data| link_data.iter().map(|link_json| match link_json {
705                Value::Object(data) => Link::from_json(&link, data),
706                Value::String(s) => Link { name: link.to_string(), href: Some(s.clone()), templated: false, title: None },
707                _ => Link { name: link.to_string(), href: Some(link_json.to_string()), templated: false, title: None }
708              }).collect())
709              .ok_or_else(|| PactBrokerError::LinkError(format!("Link is malformed, expected an object but got {}. URL: '{}', LINK: '{}'",
710                  link_data, self.url, link))),
711          None => Err(PactBrokerError::LinkError(format!("Link '{}' was not found in the response, only the following links where found: {:?}. URL: '{}', LINK: '{}'",
712            link, json.as_object().unwrap_or(&json!({}).as_object().unwrap()).keys().join(", "), self.url, link)))
713        },
714        None => Err(PactBrokerError::LinkError(format!("Expected a HAL+JSON response from the pact broker, but got a response with no '_links'. URL: '{}', LINK: '{}'",
715          self.url, link)))
716      }
717    }
718    }
719
720    pub async fn post_json(
721        &self,
722        url: &str,
723        body: &str,
724        headers: Option<HashMap<String, String>>,
725    ) -> Result<serde_json::Value, PactBrokerError> {
726        trace!("post_json(url='{}', body='{}')", url, body);
727
728        self.send_document(url, body, Method::POST, headers).await
729    }
730
731    pub async fn put_json(
732        &self,
733        url: &str,
734        body: &str,
735        headers: Option<HashMap<String, String>>,
736    ) -> Result<serde_json::Value, PactBrokerError> {
737        trace!("put_json(url='{}', body='{}')", url, body);
738
739        self.send_document(url, body, Method::PUT, headers).await
740    }
741    pub async fn patch_json(
742        &self,
743        url: &str,
744        body: &str,
745        headers: Option<HashMap<String, String>>,
746    ) -> Result<serde_json::Value, PactBrokerError> {
747        trace!("put_json(url='{}', body='{}')", url, body);
748
749        self.send_document(url, body, Method::PATCH, headers).await
750    }
751
752    async fn send_document(
753        &self,
754        url: &str,
755        body: &str,
756        method: Method,
757        headers: Option<HashMap<String, String>>,
758    ) -> Result<Value, PactBrokerError> {
759        let method_type = method.clone();
760        debug!("Sending JSON to {} using {}: {}", url, method, body);
761
762        let base_url = &self.url.parse::<Url>()?;
763        let url = if url.starts_with("/") {
764            base_url.join(url)?
765        } else {
766            let url = url.parse::<Url>()?;
767            base_url.join(&url.path())?
768        };
769
770        let request_builder = match self.auth {
771            Some(ref auth) => match auth {
772                HttpAuth::User(username, password) => self
773                    .client
774                    .request(method, url.clone())
775                    .basic_auth(username, password.clone()),
776                HttpAuth::Token(token) => {
777                    self.client.request(method, url.clone()).bearer_auth(token)
778                }
779                _ => self.client.request(method, url.clone()),
780            },
781            None => self.client.request(method, url.clone()),
782        }
783        .header("Accept", "application/hal+json")
784        .body(body.to_string());
785
786        // Add any additional headers if provided
787
788        let request_builder = if let Some(ref headers) = headers {
789            headers
790                .iter()
791                .fold(request_builder, |builder, (key, value)| {
792                    builder.header(key.as_str(), value.as_str())
793                })
794        } else {
795            request_builder
796        };
797
798        let request_builder = if method_type == Method::PATCH {
799            request_builder.header("Content-Type", "application/merge-patch+json")
800        } else {
801            request_builder.header("Content-Type", "application/json")
802        };
803        let response = with_retries(self.retries, request_builder).await;
804        match response {
805            Ok(res) => {
806                self.parse_broker_response(url.path().to_string(), res)
807                    .await
808            }
809            Err(err) => Err(PactBrokerError::IoError(format!(
810                "Failed to send JSON to the pact broker URL '{}' - IoError {}",
811                url, err
812            ))),
813        }
814    }
815
816    fn with_doc_context(self, doc_attributes: &[Link]) -> Result<HALClient, PactBrokerError> {
817        let links: serde_json::Map<String, serde_json::Value> = doc_attributes
818            .iter()
819            .map(|link| (link.name.clone(), link.as_json()))
820            .collect();
821        let links_json = json!({
822          "_links": json!(links)
823        });
824        Ok(self.update_path_info(links_json))
825    }
826}
827
828fn handle_validation_errors(body: Value) -> PactBrokerError {
829    match &body {
830        Value::Object(attrs) => {
831            // Extract notices if present
832            let notices: Vec<Notice> = attrs
833                .get("notices")
834                .and_then(|n| n.as_array())
835                .map(|notices_array| {
836                    notices_array
837                        .iter()
838                        .filter_map(|notice| serde_json::from_value::<Notice>(notice.clone()).ok())
839                        .collect()
840                })
841                .unwrap_or_default();
842
843            if let Some(errors) = attrs.get("errors") {
844                let error_messages = match errors {
845                    Value::Array(values) => values.iter().map(|v| json_to_string(v)).collect(),
846                    Value::Object(errors) => errors
847                        .iter()
848                        .map(|(field, errors)| match errors {
849                            Value::String(error) => format!("{}: {}", field, error),
850                            Value::Array(errors) => format!(
851                                "{}: {}",
852                                field,
853                                errors.iter().map(|err| json_to_string(err)).join(", ")
854                            ),
855                            _ => format!("{}: {}", field, errors),
856                        })
857                        .collect(),
858                    Value::String(s) => vec![s.clone()],
859                    _ => vec![errors.to_string()],
860                };
861
862                if !notices.is_empty() {
863                    PactBrokerError::ValidationErrorWithNotices(error_messages, notices)
864                } else {
865                    PactBrokerError::ValidationError(error_messages)
866                }
867            } else if !notices.is_empty() {
868                // Even if there are no explicit errors, notices might contain error information
869                let notice_messages = notices.iter().map(|n| n.text.clone()).collect();
870                PactBrokerError::ValidationErrorWithNotices(notice_messages, notices)
871            } else {
872                PactBrokerError::ValidationError(vec![body.to_string()])
873            }
874        }
875        Value::String(s) => PactBrokerError::ValidationError(vec![s.clone()]),
876        _ => PactBrokerError::ValidationError(vec![body.to_string()]),
877    }
878}
879
880impl HALClient {
881    pub fn setup(url: &str, auth: Option<HttpAuth>, ssl_options: SslOptions) -> HALClient {
882        let mut builder = reqwest::Client::builder().user_agent(format!(
883            "{}/{}",
884            env!("CARGO_PKG_NAME"),
885            env!("CARGO_PKG_VERSION")
886        ));
887
888        debug!("Using ssl_options: {:?}", ssl_options);
889        if let Some(ref path) = ssl_options.ssl_cert_path {
890            if let Ok(cert_bytes) = std::fs::read(path) {
891                if let Ok(cert) = reqwest::Certificate::from_pem_bundle(&cert_bytes) {
892                    debug!("Adding SSL certificate from path: {}", path);
893                    for c in cert {
894                        builder = builder.add_root_certificate(c.clone());
895                    }
896                }
897            } else {
898                debug!(
899                    "Could not read SSL certificate from provided path: {}",
900                    path
901                );
902            }
903        }
904        if ssl_options.skip_ssl {
905            builder = builder.danger_accept_invalid_certs(true);
906            debug!("Skipping SSL certificate validation");
907        }
908        if !ssl_options.use_root_trust_store {
909            builder = builder.tls_built_in_root_certs(false);
910            debug!("Disabling root trust store for SSL");
911        }
912
913        let built_client = builder.build().unwrap();
914        let client = ClientBuilder::new(built_client)
915            .with(TracingMiddleware::default())
916            .with(OtelPropagatorMiddleware)
917            .build();
918
919        HALClient {
920            client,
921            url: url.to_string(),
922            path_info: None,
923            auth,
924            custom_headers: None,
925            retries: 3,
926            ssl_options,
927        }
928    }
929}
930
931pub fn links_from_json(json: &Value) -> Vec<Link> {
932    match json.get("_links") {
933        Some(json) => match json {
934            Value::Object(v) => v
935                .iter()
936                .map(|(link, json)| match json {
937                    Value::Object(attr) => Link::from_json(link, attr),
938                    _ => Link {
939                        name: link.clone(),
940                        ..Link::default()
941                    },
942                })
943                .collect(),
944            _ => vec![],
945        },
946        None => vec![],
947    }
948}
949
950/// Fetches the pacts from the broker that match the provider name
951pub async fn fetch_pacts_from_broker(
952    broker_url: &str,
953    provider_name: &str,
954    auth: Option<HttpAuth>,
955    ssl_options: SslOptions,
956    custom_headers: Option<CustomHeaders>,
957) -> anyhow::Result<
958    Vec<
959        anyhow::Result<(
960            Box<dyn Pact + Send + Sync + RefUnwindSafe>,
961            Option<PactVerificationContext>,
962            Vec<Link>,
963        )>,
964    >,
965> {
966    trace!(
967        "fetch_pacts_from_broker(broker_url='{}', provider_name='{}', auth={})",
968        broker_url,
969        provider_name,
970        auth.clone().unwrap_or_default()
971    );
972
973    let mut hal_client = HALClient::with_url(broker_url, auth, ssl_options, custom_headers);
974    let template_values = hashmap! { "provider".to_string() => provider_name.to_string() };
975
976    hal_client = hal_client
977        .navigate("pb:latest-provider-pacts", &template_values)
978        .await
979        .map_err(move |err| match err {
980            PactBrokerError::NotFound(_) => PactBrokerError::NotFound(format!(
981                "No pacts for provider '{}' where found in the pact broker. URL: '{}'",
982                provider_name, broker_url
983            )),
984            _ => err,
985        })?;
986
987    let pact_links = hal_client.clone().iter_links("pacts")?;
988
989    let results: Vec<_> = futures::stream::iter(pact_links)
990        .map(|ref pact_link| {
991          match pact_link.href {
992            Some(_) => Ok((hal_client.clone(), pact_link.clone())),
993            None => Err(
994              PactBrokerError::LinkError(
995                format!(
996                  "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{:?}'",
997                  &hal_client.url,
998                  pact_link
999                )
1000              )
1001            )
1002          }
1003        })
1004        .and_then(|(hal_client, pact_link)| async {
1005          let pact_json = hal_client.fetch_url(
1006            &pact_link.clone(),
1007            &template_values.clone()
1008          ).await?;
1009          Ok((pact_link, pact_json))
1010        })
1011        .map(|result| {
1012          match result {
1013            Ok((pact_link, pact_json)) => {
1014              let href = pact_link.href.unwrap_or_default();
1015              let links = links_from_json(&pact_json);
1016              load_pact_from_json(href.as_str(), &pact_json)
1017                .map(|pact| (pact, None, links))
1018            },
1019            Err(err) => Err(err.into())
1020          }
1021        })
1022        .into_stream()
1023        .collect()
1024        .await;
1025
1026    Ok(results)
1027}
1028
1029/// Fetch Pacts from the broker using the "provider-pacts-for-verification" endpoint
1030pub async fn fetch_pacts_dynamically_from_broker(
1031    broker_url: &str,
1032    provider_name: String,
1033    pending: bool,
1034    include_wip_pacts_since: Option<String>,
1035    provider_tags: Vec<String>,
1036    provider_branch: Option<String>,
1037    consumer_version_selectors: Vec<ConsumerVersionSelector>,
1038    auth: Option<HttpAuth>,
1039    ssl_options: SslOptions,
1040    headers: Option<HashMap<String, String>>,
1041    custom_headers: Option<CustomHeaders>,
1042) -> anyhow::Result<
1043    Vec<
1044        Result<
1045            (
1046                Box<dyn Pact + Send + Sync + RefUnwindSafe>,
1047                Option<PactVerificationContext>,
1048                Vec<Link>,
1049            ),
1050            PactBrokerError,
1051        >,
1052    >,
1053> {
1054    trace!(
1055        "fetch_pacts_dynamically_from_broker(broker_url='{}', provider_name='{}', pending={}, \
1056    include_wip_pacts_since={:?}, provider_tags: {:?}, consumer_version_selectors: {:?}, auth={})",
1057        broker_url,
1058        provider_name,
1059        pending,
1060        include_wip_pacts_since,
1061        provider_tags,
1062        consumer_version_selectors,
1063        auth.clone().unwrap_or_default()
1064    );
1065
1066    let mut hal_client = HALClient::with_url(broker_url, auth, ssl_options, custom_headers);
1067    let template_values = hashmap! { "provider".to_string() => provider_name.clone() };
1068
1069    hal_client = hal_client
1070        .navigate("pb:provider-pacts-for-verification", &template_values)
1071        .await
1072        .map_err(move |err| match err {
1073            PactBrokerError::NotFound(_) => PactBrokerError::NotFound(format!(
1074                "No pacts for provider '{}' were found in the pact broker. URL: '{}'",
1075                provider_name.clone(),
1076                broker_url
1077            )),
1078            _ => err,
1079        })?;
1080
1081    // Construct the Pacts for verification payload
1082    let pacts_for_verification = PactsForVerificationRequest {
1083        provider_version_tags: provider_tags,
1084        provider_version_branch: provider_branch,
1085        include_wip_pacts_since,
1086        consumer_version_selectors,
1087        include_pending_status: pending,
1088    };
1089    let request_body = serde_json::to_string(&pacts_for_verification).unwrap();
1090
1091    // Post the verification request
1092    let response = match hal_client.find_link("self") {
1093        Ok(link) => {
1094            let link = hal_client.clone().parse_link_url(&link, &hashmap! {})?;
1095            match hal_client
1096                .clone()
1097                .post_json(link.as_str(), request_body.as_str(), headers)
1098                .await
1099            {
1100                Ok(res) => Some(res),
1101                Err(err) => {
1102                    info!("error response for pacts for verification: {} ", err);
1103                    return Err(anyhow!(err));
1104                }
1105            }
1106        }
1107        Err(e) => return Err(anyhow!(e)),
1108    };
1109
1110    // Find all of the Pact links
1111    let pact_links = match response {
1112        Some(v) => {
1113            let pfv: PactsForVerificationResponse = serde_json::from_value(v)
1114                .map_err(|err| {
1115                    trace!(
1116                        "Failed to deserialise PactsForVerificationResponse: {}",
1117                        err
1118                    );
1119                    err
1120                })
1121                .unwrap_or(PactsForVerificationResponse {
1122                    embedded: PactsForVerificationBody { pacts: vec![] },
1123                });
1124            trace!(?pfv, "got pacts for verification response");
1125
1126            if pfv.embedded.pacts.len() == 0 {
1127                return Err(anyhow!(PactBrokerError::NotFound(format!(
1128                    "No pacts were found for this provider"
1129                ))));
1130            };
1131
1132            let links: Result<Vec<(Link, PactVerificationContext)>, PactBrokerError> = pfv.embedded.pacts.iter().map(| p| {
1133          match p.links.get("self") {
1134            Some(l) => Ok((l.clone(), p.into())),
1135            None => Err(
1136              PactBrokerError::LinkError(
1137                format!(
1138                  "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', PATH: '{:?}'",
1139                  &hal_client.url,
1140                  &p.links,
1141                )
1142              )
1143            )
1144          }
1145        }).collect();
1146
1147            links
1148        }
1149        None => Err(PactBrokerError::NotFound(format!(
1150            "No pacts were found for this provider"
1151        ))),
1152    }?;
1153
1154    let results: Vec<_> = futures::stream::iter(pact_links)
1155      .map(|(ref pact_link, ref context)| {
1156        match pact_link.href {
1157          Some(_) => Ok((hal_client.clone(), pact_link.clone(), context.clone())),
1158          None => Err(
1159            PactBrokerError::LinkError(
1160              format!(
1161                "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{:?}'",
1162                &hal_client.url,
1163                pact_link
1164              )
1165            )
1166          )
1167        }
1168      })
1169      .and_then(|(hal_client, pact_link, context)| async {
1170        let pact_json = hal_client.fetch_url(
1171          &pact_link.clone(),
1172          &template_values.clone()
1173        ).await?;
1174        Ok((pact_link, pact_json, context))
1175      })
1176      .map(|result| {
1177        match result {
1178          Ok((pact_link, pact_json, context)) => {
1179            let href = pact_link.href.unwrap_or_default();
1180            let links = links_from_json(&pact_json);
1181            load_pact_from_json(href.as_str(), &pact_json)
1182              .map(|pact| (pact, Some(context), links))
1183              .map_err(|err| PactBrokerError::ContentError(format!("{}", err)))
1184          },
1185          Err(err) => Err(err)
1186        }
1187      })
1188      .into_stream()
1189      .collect()
1190      .await;
1191
1192    Ok(results)
1193}
1194
1195/// Fetch the Pact from the given URL, using any required authentication. This will use a GET
1196/// request to the given URL and parse the result into a Pact model. It will also look for any HAL
1197/// links in the response, returning those if found.
1198pub async fn fetch_pact_from_url(
1199    url: &str,
1200    auth: &Option<HttpAuth>,
1201) -> anyhow::Result<(Box<dyn Pact + Send + Sync + RefUnwindSafe>, Vec<Link>)> {
1202    let url = url.to_string();
1203    let auth = auth.clone();
1204    let (url, pact_json) =
1205        tokio::task::spawn_blocking(move || http_utils::fetch_json_from_url(&url, &auth)).await??;
1206    let pact = load_pact_from_json(&url, &pact_json)?;
1207    let links = links_from_json(&pact_json);
1208    Ok((pact, links))
1209}
1210
1211async fn publish_provider_tags(
1212    hal_client: &HALClient,
1213    links: &[Link],
1214    provider_tags: Vec<String>,
1215    version: &str,
1216    headers: Option<HashMap<String, String>>,
1217) -> Result<(), PactBrokerError> {
1218    let hal_client = hal_client
1219        .clone()
1220        .with_doc_context(links)?
1221        .navigate("pb:provider", &hashmap! {})
1222        .await?;
1223    match hal_client.find_link("pb:version-tag") {
1224        Ok(link) => {
1225            for tag in &provider_tags {
1226                let template_values = hashmap! {
1227                  "version".to_string() => version.to_string(),
1228                  "tag".to_string() => tag.clone()
1229                };
1230                match hal_client
1231                    .clone()
1232                    .put_json(
1233                        hal_client
1234                            .clone()
1235                            .parse_link_url(&link, &template_values)?
1236                            .as_str(),
1237                        "{}",
1238                        headers.clone(),
1239                    )
1240                    .await
1241                {
1242                    Ok(_) => debug!("Pushed tag {} for provider version {}", tag, version),
1243                    Err(err) => {
1244                        error!(
1245                            "Failed to push tag {} for provider version {}",
1246                            tag, version
1247                        );
1248                        return Err(err);
1249                    }
1250                }
1251            }
1252            Ok(())
1253        }
1254        Err(_) => Err(PactBrokerError::LinkError(
1255            "Can't publish provider tags as there is no 'pb:version-tag' link".to_string(),
1256        )),
1257    }
1258}
1259
1260async fn publish_provider_branch(
1261    hal_client: &HALClient,
1262    links: &[Link],
1263    branch: &str,
1264    version: &str,
1265    headers: Option<HashMap<String, String>>,
1266) -> Result<(), PactBrokerError> {
1267    let hal_client = hal_client
1268        .clone()
1269        .with_doc_context(links)?
1270        .navigate("pb:provider", &hashmap! {})
1271        .await?;
1272
1273    match hal_client.find_link("pb:branch-version") {
1274    Ok(link) => {
1275      let template_values = hashmap! {
1276        "branch".to_string() => branch.to_string(),
1277        "version".to_string() => version.to_string(),
1278      };
1279      match hal_client.clone().put_json(hal_client.clone().parse_link_url(&link, &template_values)?.as_str(), "{}",headers).await {
1280        Ok(_) => debug!("Pushed branch {} for provider version {}", branch, version),
1281        Err(err) => {
1282          error!("Failed to push branch {} for provider version {}", branch, version);
1283          return Err(err);
1284        }
1285      }
1286      Ok(())
1287    },
1288    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()))
1289  }
1290}
1291
1292#[skip_serializing_none]
1293#[derive(Serialize, Deserialize, Debug, Clone)]
1294#[serde(rename_all = "camelCase")]
1295/// Structure to represent a HAL link
1296pub struct ConsumerVersionSelector {
1297    /// Application name to filter the results on
1298    pub consumer: Option<String>,
1299    /// Tag
1300    pub tag: Option<String>,
1301    /// Fallback tag if Tag doesn't exist
1302    pub fallback_tag: Option<String>,
1303    /// Only select the latest (if false, this selects all pacts for a tag)
1304    pub latest: Option<bool>,
1305    /// Applications that have been deployed or released
1306    pub deployed_or_released: Option<bool>,
1307    /// Applications that have been deployed
1308    pub deployed: Option<bool>,
1309    /// Applications that have been released
1310    pub released: Option<bool>,
1311    /// Applications in a given environment
1312    pub environment: Option<String>,
1313    /// Applications with the default branch set in the broker
1314    pub main_branch: Option<bool>,
1315    /// Applications with the given branch
1316    pub branch: Option<String>,
1317    /// Applications that match the the provider version branch sent during verification
1318    pub matching_branch: Option<bool>,
1319}
1320
1321#[derive(Serialize, Deserialize, Debug, Clone)]
1322#[serde(rename_all = "camelCase")]
1323struct PactsForVerificationResponse {
1324    #[serde(rename(deserialize = "_embedded"))]
1325    pub embedded: PactsForVerificationBody,
1326}
1327
1328#[derive(Serialize, Deserialize, Debug, Clone)]
1329#[serde(rename_all = "camelCase")]
1330struct PactsForVerificationBody {
1331    pub pacts: Vec<PactForVerification>,
1332}
1333
1334#[derive(Serialize, Deserialize, Debug, Clone)]
1335#[serde(rename_all = "camelCase")]
1336struct PactForVerification {
1337    pub short_description: String,
1338    #[serde(rename(deserialize = "_links"))]
1339    pub links: HashMap<String, Link>,
1340    pub verification_properties: Option<PactVerificationProperties>,
1341}
1342
1343#[skip_serializing_none]
1344#[derive(Serialize, Deserialize, Debug, Clone)]
1345#[serde(rename_all = "camelCase")]
1346/// Request to send to determine the pacts to verify
1347pub struct PactsForVerificationRequest {
1348    /// Provider tags to use for determining pending pacts (if enabled)
1349    #[serde(skip_serializing_if = "Vec::is_empty")]
1350    pub provider_version_tags: Vec<String>,
1351    /// Enable pending pacts feature
1352    pub include_pending_status: bool,
1353    /// Find WIP pacts after given date
1354    pub include_wip_pacts_since: Option<String>,
1355    /// Detailed pact selection criteria , see https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/
1356    pub consumer_version_selectors: Vec<ConsumerVersionSelector>,
1357    /// Current provider version branch if used (instead of tags)
1358    pub provider_version_branch: Option<String>,
1359}
1360
1361#[skip_serializing_none]
1362#[derive(Serialize, Deserialize, Debug, Clone)]
1363#[serde(rename_all = "camelCase")]
1364/// Provides the context on why a Pact was included
1365pub struct PactVerificationContext {
1366    /// Description
1367    pub short_description: String,
1368    /// Properties
1369    pub verification_properties: PactVerificationProperties,
1370}
1371
1372impl From<&PactForVerification> for PactVerificationContext {
1373    fn from(value: &PactForVerification) -> Self {
1374        PactVerificationContext {
1375            short_description: value.short_description.clone(),
1376            verification_properties: value.verification_properties.clone().unwrap_or_default(),
1377        }
1378    }
1379}
1380
1381#[skip_serializing_none]
1382#[derive(Serialize, Deserialize, Debug, Clone, Default)]
1383#[serde(rename_all = "camelCase")]
1384/// Properties associated with the verification context
1385pub struct PactVerificationProperties {
1386    #[serde(default)]
1387    /// If the Pact is pending
1388    pub pending: bool,
1389    /// Notices provided by the Pact Broker
1390    pub notices: Vec<HashMap<String, String>>,
1391}
1392
1393#[cfg(test)]
1394mod hal_client_custom_headers_tests {
1395    use super::*;
1396    use crate::cli::pact_broker::main::types::SslOptions;
1397    use std::collections::HashMap;
1398
1399    fn create_test_custom_headers() -> CustomHeaders {
1400        let mut headers = HashMap::new();
1401        headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
1402        headers.insert("X-API-Key".to_string(), "secret-key".to_string());
1403        CustomHeaders { headers }
1404    }
1405
1406    fn create_cloudflare_custom_headers() -> CustomHeaders {
1407        let mut headers = HashMap::new();
1408        headers.insert(
1409            "CF-Access-Client-Id".to_string(),
1410            "client-id-123".to_string(),
1411        );
1412        headers.insert(
1413            "CF-Access-Client-Secret".to_string(),
1414            "secret-456".to_string(),
1415        );
1416        CustomHeaders { headers }
1417    }
1418
1419    #[test]
1420    fn test_hal_client_with_custom_headers() {
1421        let custom_headers = Some(create_test_custom_headers());
1422        let ssl_options = SslOptions::default();
1423
1424        let client = HALClient::with_url(
1425            "https://test.example.com",
1426            None,
1427            ssl_options,
1428            custom_headers.clone(),
1429        );
1430
1431        assert_eq!(client.url, "https://test.example.com");
1432        assert!(client.custom_headers.is_some());
1433
1434        let headers = client.custom_headers.unwrap();
1435        assert_eq!(headers.headers.len(), 2);
1436        assert_eq!(
1437            headers.headers.get("Authorization"),
1438            Some(&"Bearer test-token".to_string())
1439        );
1440        assert_eq!(
1441            headers.headers.get("X-API-Key"),
1442            Some(&"secret-key".to_string())
1443        );
1444    }
1445
1446    #[test]
1447    fn test_hal_client_with_cloudflare_headers() {
1448        let custom_headers = Some(create_cloudflare_custom_headers());
1449        let ssl_options = SslOptions::default();
1450
1451        let client = HALClient::with_url(
1452            "https://pact-broker.example.com",
1453            None,
1454            ssl_options,
1455            custom_headers.clone(),
1456        );
1457
1458        assert!(client.custom_headers.is_some());
1459
1460        let headers = client.custom_headers.unwrap();
1461        assert_eq!(headers.headers.len(), 2);
1462        assert_eq!(
1463            headers.headers.get("CF-Access-Client-Id"),
1464            Some(&"client-id-123".to_string())
1465        );
1466        assert_eq!(
1467            headers.headers.get("CF-Access-Client-Secret"),
1468            Some(&"secret-456".to_string())
1469        );
1470    }
1471
1472    #[test]
1473    fn test_hal_client_without_custom_headers() {
1474        let ssl_options = SslOptions::default();
1475
1476        let client = HALClient::with_url("https://test.example.com", None, ssl_options, None);
1477
1478        assert!(client.custom_headers.is_none());
1479    }
1480
1481    #[test]
1482    fn test_hal_client_with_auth_and_custom_headers() {
1483        let auth = Some(HttpAuth::Token("bearer-token".to_string()));
1484        let custom_headers = Some(create_test_custom_headers());
1485        let ssl_options = SslOptions::default();
1486
1487        let client = HALClient::with_url(
1488            "https://test.example.com",
1489            auth.clone(),
1490            ssl_options,
1491            custom_headers,
1492        );
1493
1494        assert!(client.auth.is_some());
1495        assert!(client.custom_headers.is_some());
1496
1497        if let Some(HttpAuth::Token(token)) = client.auth {
1498            assert_eq!(token, "bearer-token");
1499        }
1500    }
1501
1502    #[test]
1503    fn test_apply_custom_headers_with_mock_request() {
1504        use reqwest::Client;
1505        use reqwest_middleware::ClientBuilder;
1506
1507        let custom_headers = Some(create_test_custom_headers());
1508        let ssl_options = SslOptions::default();
1509
1510        let client = HALClient::with_url(
1511            "https://test.example.com",
1512            None,
1513            ssl_options,
1514            custom_headers,
1515        );
1516
1517        // Create a mock request builder to test header application
1518        let reqwest_client = Client::new();
1519        let middleware_client = ClientBuilder::new(reqwest_client).build();
1520        let request_builder = middleware_client.get("https://test.example.com/test");
1521
1522        // Apply custom headers
1523        let modified_builder = client.apply_custom_headers(request_builder);
1524
1525        // Build the request to inspect headers
1526        let request = modified_builder.build().unwrap();
1527
1528        // Check that custom headers were applied
1529        assert!(request.headers().contains_key("authorization"));
1530        assert!(request.headers().contains_key("x-api-key"));
1531
1532        assert_eq!(
1533            request
1534                .headers()
1535                .get("authorization")
1536                .unwrap()
1537                .to_str()
1538                .unwrap(),
1539            "Bearer test-token"
1540        );
1541        assert_eq!(
1542            request
1543                .headers()
1544                .get("x-api-key")
1545                .unwrap()
1546                .to_str()
1547                .unwrap(),
1548            "secret-key"
1549        );
1550    }
1551
1552    #[test]
1553    fn test_apply_custom_headers_without_headers() {
1554        use reqwest::Client;
1555        use reqwest_middleware::ClientBuilder;
1556
1557        let ssl_options = SslOptions::default();
1558
1559        let client = HALClient::with_url("https://test.example.com", None, ssl_options, None);
1560
1561        // Create a mock request builder
1562        let reqwest_client = Client::new();
1563        let middleware_client = ClientBuilder::new(reqwest_client).build();
1564        let request_builder = middleware_client.get("https://test.example.com/test");
1565
1566        // Apply custom headers (should be no-op)
1567        let modified_builder = client.apply_custom_headers(request_builder);
1568
1569        // Build the request to inspect headers
1570        let request = modified_builder.build().unwrap();
1571
1572        // Should not contain our test headers
1573        assert!(!request.headers().contains_key("authorization"));
1574        assert!(!request.headers().contains_key("x-api-key"));
1575    }
1576
1577    #[test]
1578    fn test_custom_headers_struct_creation() {
1579        let mut headers = HashMap::new();
1580        headers.insert("Test-Header".to_string(), "test-value".to_string());
1581
1582        let custom_headers = CustomHeaders { headers };
1583
1584        assert_eq!(custom_headers.headers.len(), 1);
1585        assert_eq!(
1586            custom_headers.headers.get("Test-Header"),
1587            Some(&"test-value".to_string())
1588        );
1589    }
1590
1591    #[test]
1592    fn test_custom_headers_empty() {
1593        let headers = HashMap::new();
1594        let custom_headers = CustomHeaders { headers };
1595
1596        assert_eq!(custom_headers.headers.len(), 0);
1597        assert!(custom_headers.headers.is_empty());
1598    }
1599
1600    #[test]
1601    fn test_custom_headers_case_sensitivity() {
1602        let mut headers = HashMap::new();
1603        headers.insert("content-type".to_string(), "application/json".to_string());
1604        headers.insert("Content-Type".to_string(), "text/plain".to_string());
1605
1606        let custom_headers = CustomHeaders { headers };
1607
1608        // Both should exist as separate entries (case sensitive keys)
1609        assert_eq!(custom_headers.headers.len(), 2);
1610        assert_eq!(
1611            custom_headers.headers.get("content-type"),
1612            Some(&"application/json".to_string())
1613        );
1614        assert_eq!(
1615            custom_headers.headers.get("Content-Type"),
1616            Some(&"text/plain".to_string())
1617        );
1618    }
1619}
1620
1621#[cfg(test)]
1622mod tests
1623{
1624    use expectest::expect;
1625  use expectest::prelude::*;
1626  use pact_models::{Consumer, PactSpecification, Provider};
1627  use pact_models::prelude::RequestResponsePact;
1628  use pact_models::sync_interaction::RequestResponseInteraction;
1629  use pretty_assertions::assert_eq;
1630
1631  use pact_consumer::*;
1632  use pact_consumer::prelude::*;
1633
1634  use super::*;
1635  use super::{content_type, json_content_type};
1636
1637    #[test]
1638  fn resolve_path_test() {
1639    let client = HALClient::with_url("not a URL", None, SslOptions::default(), None);
1640    expect!(client.resolve_path("/any")).to(be_err());
1641
1642    let client = HALClient::with_url("http://localhost-ip4:1234", None, SslOptions::default(), None);
1643    expect!(client.resolve_path("")).to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1644    expect!(client.resolve_path("/")).to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1645    expect!(client.resolve_path("/any")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1646    expect!(client.resolve_path("any")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1647    expect!(client.resolve_path("any/sub-path")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/any/sub-path").unwrap()));
1648    expect!(client.resolve_path("/base-path")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1649    expect!(client.resolve_path("/base-path/")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/").unwrap()));
1650    expect!(client.resolve_path("/base-path/sub-path")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/sub-path").unwrap()));
1651
1652    let client = HALClient::with_url("http://localhost-ip4:1234/base-path", None, SslOptions::default(), None);
1653    expect!(client.resolve_path("")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1654    expect!(client.resolve_path("/")).to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1655    expect!(client.resolve_path("/any")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1656    expect!(client.resolve_path("any")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/any").unwrap()));
1657    expect!(client.resolve_path("any/sub-path")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/any/sub-path").unwrap()));
1658    expect!(client.resolve_path("/base-path")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1659    expect!(client.resolve_path("/base-path/")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/").unwrap()));
1660    expect!(client.resolve_path("/base-path/sub-path")).to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/sub-path").unwrap()));
1661  }
1662
1663  #[test_log::test(tokio::test)]
1664  async fn navigate_first_retrieves_the_root_resource() {
1665    let pact_broker = PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1666      .interaction("a request to a hal resource", "", |mut i| async move {
1667        i.request.path("/");
1668        i.response
1669          .header("Content-Type", "application/hal+json")
1670          .body("{\"_links\":{\"next\":{\"href\":\"/abc\"},\"prev\":{\"href\":\"/def\"}}}");
1671        i
1672      })
1673      .await
1674      .interaction("a request to next", "", |mut i| async move {
1675        i.request.path("/abc");
1676        i.response
1677          .header("Content-Type", "application/json")
1678          .json_body(json_pattern!("Yay! You found your way here"));
1679        i
1680      })
1681      .await
1682      .start_mock_server(None, Some(MockServerConfig::default()));
1683
1684    let client = HALClient::with_url(pact_broker.url().as_str(), None, SslOptions::default(), None);
1685    let result = client.navigate("next", &hashmap!{}).await.unwrap();
1686    expect!(result.path_info).to(be_some().value(serde_json::Value::String("Yay! You found your way here".to_string())));
1687  }
1688
1689  #[test_log::test(tokio::test)]
1690  async fn navigate_will_not_retrieve_the_root_resource_if_a_path_is_already_set() {
1691    let pact_broker = PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1692      .interaction("a request to next", "", |mut i| async move {
1693        i.request.path("/abc");
1694        i.response
1695          .header("Content-Type", "application/json")
1696          .json_body(json_pattern!("Yay! You found your way here"));
1697        i
1698      })
1699      .await
1700      .start_mock_server(None, Some(MockServerConfig::default()));
1701
1702    let mut client = HALClient::with_url(pact_broker.url().as_str(), None, SslOptions::default(), None);
1703    client.path_info = Some(json!({
1704      "_links": {
1705        "next": { "href": "/abc" },
1706        "prev": { "href": "/def" }
1707      }
1708    }));
1709    let result = client.navigate("next", &hashmap!{}).await.unwrap();
1710    expect!(result.path_info).to(be_some().value(serde_json::Value::String("Yay! You found your way here".to_string())));
1711  }
1712
1713  #[test_log::test(tokio::test)]
1714  async fn navigate_takes_context_paths_into_account() {
1715    let pact_broker = PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1716      .interaction("a request to a hal resource with base path", "", |mut i| async move {
1717        i.request.path("/base-path");
1718        i.response
1719          .header("Content-Type", "application/hal+json")
1720          .body("{\"_links\":{\"next\":{\"href\":\"/base-path/abc\"},\"prev\":{\"href\":\"/base-path/def\"}}}");
1721        i
1722      })
1723      .await
1724      .interaction("a request to next with a base path", "", |mut i| async move {
1725        i.request.path("/base-path/abc");
1726        i.response
1727          .header("Content-Type", "application/json")
1728          .json_body(json_pattern!("Yay! You found your way here"));
1729        i
1730      })
1731      .await
1732      .start_mock_server(None, Some(MockServerConfig::default()));
1733
1734    let client = HALClient::with_url(pact_broker.url().join("/base-path").unwrap().as_str(), None, SslOptions::default(), None);
1735    let result = client.navigate("next", &hashmap!{}).await.unwrap();
1736    expect!(result.path_info).to(be_some().value(serde_json::Value::String("Yay! You found your way here".to_string())));
1737  }
1738}