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;
46// for otel
47use crate::cli::utils::{CYAN, GREEN, RED, YELLOW};
48use http::Extensions;
49use opentelemetry::Context;
50use opentelemetry::global;
51use opentelemetry_http::HeaderInjector;
52use reqwest::Request;
53use reqwest::Response;
54use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
55use reqwest_middleware::{Middleware, Next};
56use reqwest_retry::{DefaultRetryableStrategy, Retryable, RetryableStrategy};
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(serde_json::Value::Bool(b)) => *b,
95        _ => false,
96    }
97}
98
99fn as_string(json: &Value) -> String {
100    match *json {
101        serde_json::Value::String(ref s) => s.clone(),
102        _ => format!("{}", json),
103    }
104}
105
106fn content_type(response: &reqwest::Response) -> String {
107    match response.headers().get("content-type") {
108        Some(value) => value.to_str().unwrap_or("text/plain").into(),
109        None => "text/plain".to_string(),
110    }
111}
112
113fn json_content_type(response: &reqwest::Response) -> bool {
114    match content_type(response).parse::<mime::Mime>() {
115        Ok(mime) => matches!(
116            (
117                mime.type_().as_str(),
118                mime.subtype().as_str(),
119                mime.suffix()
120            ),
121            ("application", "json", None) | ("application", "hal", Some(mime::JSON))
122        ),
123        Err(_) => false,
124    }
125}
126
127fn find_entry(map: &serde_json::Map<String, Value>, key: &str) -> Option<(String, Value)> {
128    match map.keys().find(|k| k.to_lowercase() == key.to_lowercase()) {
129        Some(k) => map.get(k).map(|v| (key.to_string(), v.clone())),
130        None => None,
131    }
132}
133
134/// Errors that can occur with a Pact Broker
135#[derive(Debug, Clone, thiserror::Error)]
136pub enum PactBrokerError {
137    /// Error with a HAL link
138    #[error("Error with a HAL link - {0}")]
139    LinkError(String),
140    /// Error with the content of a HAL resource
141    #[error("Error with the content of a HAL resource - {0}")]
142    ContentError(String),
143    #[error("IO Error - {0}")]
144    /// IO Error
145    IoError(String),
146    /// Link/Resource was not found
147    #[error("Link/Resource was not found - {0}")]
148    NotFound(String),
149    /// Invalid URL
150    #[error("Invalid URL - {0}")]
151    UrlError(String),
152    /// Validation error
153    #[error("failed validation - {0:?}")]
154    ValidationError(Vec<String>),
155    /// Validation error with notices
156    #[error("failed validation - {0:?}")]
157    ValidationErrorWithNotices(Vec<String>, Vec<Notice>),
158}
159
160impl PartialEq<String> for PactBrokerError {
161    fn eq(&self, other: &String) -> bool {
162        let mut buffer = String::new();
163        match self {
164            PactBrokerError::LinkError(s) => buffer.push_str(s),
165            PactBrokerError::ContentError(s) => buffer.push_str(s),
166            PactBrokerError::IoError(s) => buffer.push_str(s),
167            PactBrokerError::NotFound(s) => buffer.push_str(s),
168            PactBrokerError::UrlError(s) => buffer.push_str(s),
169            PactBrokerError::ValidationError(errors) => {
170                buffer.push_str(errors.iter().join(", ").as_str())
171            }
172            PactBrokerError::ValidationErrorWithNotices(errors, _) => {
173                buffer.push_str(errors.iter().join(", ").as_str())
174            }
175        };
176        buffer == *other
177    }
178}
179
180impl PartialEq<&str> for PactBrokerError {
181    fn eq(&self, other: &&str) -> bool {
182        let message = match self {
183            PactBrokerError::LinkError(s) => s.clone(),
184            PactBrokerError::ContentError(s) => s.clone(),
185            PactBrokerError::IoError(s) => s.clone(),
186            PactBrokerError::NotFound(s) => s.clone(),
187            PactBrokerError::UrlError(s) => s.clone(),
188            PactBrokerError::ValidationError(errors) => errors.iter().join(", "),
189            PactBrokerError::ValidationErrorWithNotices(errors, _) => errors.iter().join(", "),
190        };
191        message.as_str() == *other
192    }
193}
194
195impl From<url::ParseError> for PactBrokerError {
196    fn from(err: url::ParseError) -> Self {
197        PactBrokerError::UrlError(format!("{}", err))
198    }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(default)]
203/// Structure to represent a HAL link
204pub struct Link {
205    /// Link name
206    pub name: String,
207    /// Link HREF
208    pub href: Option<String>,
209    /// If the link is templated (has expressions in the HREF that need to be expanded)
210    pub templated: bool,
211    /// Link title
212    pub title: Option<String>,
213}
214
215impl Link {
216    /// Create a link from serde JSON data
217    pub fn from_json(link: &str, link_data: &serde_json::Map<String, serde_json::Value>) -> Link {
218        Link {
219            name: link.to_string(),
220            href: find_entry(link_data, "href").map(|(_, href)| as_string(&href)),
221            templated: is_true(link_data, "templated"),
222            title: link_data.get("title").map(as_string),
223        }
224    }
225
226    /// Converts the Link into a JSON representation
227    pub fn as_json(&self) -> serde_json::Value {
228        match (self.href.clone(), self.title.clone()) {
229            (Some(href), Some(title)) => json!({
230              "href": href,
231              "title": title,
232              "templated": self.templated
233            }),
234            (Some(href), None) => json!({
235              "href": href,
236              "templated": self.templated
237            }),
238            (None, Some(title)) => json!({
239              "title": title,
240              "templated": self.templated
241            }),
242            (None, None) => json!({
243              "templated": self.templated
244            }),
245        }
246    }
247}
248
249impl Default for Link {
250    fn default() -> Self {
251        Link {
252            name: "link".to_string(),
253            href: None,
254            templated: false,
255            title: None,
256        }
257    }
258}
259
260/// HAL aware HTTP client
261#[derive(Clone)]
262pub struct HALClient {
263    pub url: String,
264    pub client: ClientWithMiddleware,
265    path_info: Option<Value>,
266    auth: Option<HttpAuth>,
267    custom_headers: Option<CustomHeaders>,
268    ssl_options: SslOptions,
269    pub retries: u8,
270}
271
272struct OtelPropagatorMiddleware;
273
274#[async_trait::async_trait]
275impl Middleware for OtelPropagatorMiddleware {
276    async fn handle(
277        &self,
278        mut req: Request,
279        extensions: &mut Extensions,
280        next: Next<'_>,
281    ) -> reqwest_middleware::Result<Response> {
282        let cx = Context::current();
283        let mut headers = reqwest::header::HeaderMap::new();
284        global::get_text_map_propagator(|propagator| {
285            propagator.inject_context(&cx, &mut HeaderInjector(&mut headers))
286        });
287        headers.append(
288            "baggage",
289            reqwest::header::HeaderValue::from_static("is_synthetic=true"),
290        );
291
292        for (key, value) in headers.iter() {
293            req.headers_mut().append(key, value.clone());
294        }
295
296        next.run(req, extensions).await
297    }
298}
299
300/// Parses the value of a `Retry-After` response header into a [`Duration`].
301///
302/// The header may be either:
303/// - an integer number of seconds (`Retry-After: 120`), or
304/// - an HTTP-date (`Retry-After: Fri, 31 Dec 1999 23:59:59 GMT`).
305///
306/// For a date in the past the returned duration is [`Duration::ZERO`].
307/// Returns `None` if the header is absent or unparseable.
308///
309/// # Arguments
310///
311/// * `response` - The HTTP response to inspect.
312///
313/// # Returns
314///
315/// The wait duration indicated by the header, or `None` if absent/unparseable.
316fn parse_retry_after(response: &Response) -> Option<std::time::Duration> {
317    let header_value = response
318        .headers()
319        .get(reqwest::header::RETRY_AFTER)?
320        .to_str()
321        .ok()?;
322
323    // Try the decimal-seconds form first (e.g. "120").
324    if let Ok(secs) = header_value.trim().parse::<u64>() {
325        return Some(std::time::Duration::from_secs(secs));
326    }
327
328    // Fall back to the HTTP-date form (e.g. "Fri, 31 Dec 1999 23:59:59 GMT").
329    if let Ok(system_time) = httpdate::parse_http_date(header_value) {
330        let delay = system_time
331            .duration_since(std::time::SystemTime::now())
332            .unwrap_or_default();
333        return Some(delay);
334    }
335
336    None
337}
338
339/// Middleware that retries transient HTTP failures and honours `Retry-After` headers.
340///
341/// Uses [`DefaultRetryableStrategy`] to classify responses: 5xx, 408, and 429 are
342/// treated as transient and retried.
343///
344/// For `429 Too Many Requests` responses the `Retry-After` header is read when
345/// present; both the decimal-seconds form (`Retry-After: 120`) and the HTTP-date
346/// form (`Retry-After: Fri, 31 Dec 1999 23:59:59 GMT`) are supported.  The parsed
347/// delay is passed to [`utils::compute_retry_delay`], which adds a ≈20 % jitter
348/// (capped at 60 s) to spread simultaneous retries across the new rate-limit window.
349///
350/// All other transient failures use exponential back-off (`10^attempt` ms).
351///
352/// Requests with streaming bodies that cannot be cloned produce an error on the first
353/// transient failure without retrying.
354struct RetryMiddleware {
355    /// Maximum number of total attempts, including the initial send.  `0` means
356    /// one attempt with no retries (same as `1`).
357    max_attempts: u8,
358}
359
360#[async_trait::async_trait]
361impl Middleware for RetryMiddleware {
362    async fn handle(
363        &self,
364        req: Request,
365        extensions: &mut Extensions,
366        next: Next<'_>,
367    ) -> reqwest_middleware::Result<Response> {
368        let max_retries = self.max_attempts.saturating_sub(1) as u32;
369        let mut n_past_retries: u32 = 0;
370
371        loop {
372            // Clone the request so we still hold it for subsequent retry iterations.
373            let cloned = req.try_clone().ok_or_else(|| {
374                reqwest_middleware::Error::Middleware(anyhow::anyhow!(
375                    "Request object is not cloneable. Are you passing a streaming body?"
376                ))
377            })?;
378
379            let result = next.clone().run(cloned, extensions).await;
380
381            // Classify the response using reqwest-retry's built-in strategy.
382            if let Some(Retryable::Transient) = DefaultRetryableStrategy.handle(&result)
383                && n_past_retries < max_retries
384            {
385                let delay = if let Ok(ref resp) = result {
386                    utils::compute_retry_delay(
387                        resp.status(),
388                        parse_retry_after(resp),
389                        n_past_retries + 1,
390                    )
391                } else {
392                    utils::compute_retry_delay(
393                        reqwest::StatusCode::INTERNAL_SERVER_ERROR,
394                        None,
395                        n_past_retries + 1,
396                    )
397                };
398                trace!(
399                    attempt = n_past_retries + 1,
400                    max_attempts = self.max_attempts,
401                    delay_ms = delay.as_millis(),
402                    "retrying transient HTTP failure"
403                );
404                tokio::time::sleep(delay).await;
405                n_past_retries += 1;
406                continue;
407            }
408
409            break result;
410        }
411    }
412}
413
414pub trait WithCurrentSpan {
415    fn with_current_span<F, R>(&self, f: F) -> R
416    where
417        F: FnOnce() -> R;
418}
419
420impl<T> WithCurrentSpan for T {
421    fn with_current_span<F, R>(&self, f: F) -> R
422    where
423        F: FnOnce() -> R,
424    {
425        let span = tracing::Span::current();
426        let _enter = span.enter();
427        f()
428    }
429}
430
431impl HALClient {
432    /// Helper method to apply custom headers to a request builder
433    fn apply_custom_headers(
434        &self,
435        mut builder: reqwest_middleware::RequestBuilder,
436    ) -> reqwest_middleware::RequestBuilder {
437        if let Some(ref custom_headers) = self.custom_headers {
438            for (name, value) in &custom_headers.headers {
439                builder = builder.header(name, value);
440            }
441        }
442        builder
443    }
444
445    /// Initialise a client with the URL and authentication
446    pub fn with_url(
447        url: &str,
448        auth: Option<HttpAuth>,
449        ssl_options: SslOptions,
450        custom_headers: Option<CustomHeaders>,
451    ) -> HALClient {
452        HALClient {
453            url: url.to_string(),
454            auth: auth.clone(),
455            custom_headers,
456            ssl_options: ssl_options.clone(),
457            ..HALClient::setup(url, auth, ssl_options)
458        }
459    }
460
461    fn update_path_info(self, path_info: serde_json::Value) -> HALClient {
462        HALClient {
463            client: self.client.clone(),
464            url: self.url.clone(),
465            path_info: Some(path_info),
466            auth: self.auth,
467            custom_headers: self.custom_headers,
468            retries: self.retries,
469            ssl_options: self.ssl_options,
470        }
471    }
472
473    /// Navigate to the resource from the link name
474    pub async fn navigate(
475        self,
476        link: &'static str,
477        template_values: &HashMap<String, String>,
478    ) -> Result<HALClient, PactBrokerError> {
479        trace!(
480            "navigate(link='{}', template_values={:?})",
481            link, template_values
482        );
483
484        let client = if self.path_info.is_none() {
485            let path_info = self.fetch("").await?;
486            self.update_path_info(path_info)
487        } else {
488            self
489        };
490
491        let path_info = client.clone().fetch_link(link, template_values).await?;
492        let client = client.update_path_info(path_info);
493
494        Ok(client)
495    }
496
497    fn find_link(&self, link: &'static str) -> Result<Link, PactBrokerError> {
498        match self.path_info {
499            None => Err(PactBrokerError::LinkError(format!("No previous resource has been fetched from the pact broker. URL: '{}', LINK: '{}'",
500                self.url, link))),
501            Some(ref json) => match json.get("_links") {
502                Some(json) => match json.get(link) {
503                    Some(link_data) => link_data.as_object()
504                        .map(|link_data| Link::from_json(link, link_data))
505                        .ok_or_else(|| PactBrokerError::LinkError(format!("Link is malformed, expected an object but got {}. URL: '{}', LINK: '{}'",
506                            link_data, self.url, link))),
507                    None => Err(PactBrokerError::LinkError(format!("Link '{}' was not found in the response, only the following links where found: {:?}. URL: '{}', LINK: '{}'",
508                        link, json.as_object().unwrap_or(json!({}).as_object().unwrap()).keys().join(", "), self.url, link)))
509                },
510                None => Err(PactBrokerError::LinkError(format!("Expected a HAL+JSON response from the pact broker, but got a response with no '_links'. URL: '{}', LINK: '{}'",
511                    self.url, link)))
512            }
513        }
514    }
515
516    async fn fetch_link(
517        self,
518        link: &'static str,
519        template_values: &HashMap<String, String>,
520    ) -> Result<Value, PactBrokerError> {
521        trace!(
522            "fetch_link(link='{}', template_values={:?})",
523            link, template_values
524        );
525
526        let link_data = self.find_link(link)?;
527
528        self.fetch_url(&link_data, template_values).await
529    }
530
531    /// Fetch the resource at the Link from the Pact broker
532    pub async fn fetch_url(
533        self,
534        link: &Link,
535        template_values: &HashMap<String, String>,
536    ) -> Result<Value, PactBrokerError> {
537        debug!(
538            "fetch_url(link={:?}, template_values={:?})",
539            link, template_values
540        );
541
542        let link_url = if link.templated {
543            debug!("Link URL is templated");
544            self.parse_link_url(link, template_values)
545        } else {
546            link.href.clone().ok_or_else(|| {
547                PactBrokerError::LinkError(format!(
548                    "Link is malformed, there is no href. URL: '{}', LINK: '{}'",
549                    self.url, link.name
550                ))
551            })
552        }?;
553
554        let base_url = self.url.parse::<Url>()?;
555        let joined_url = base_url.join(&link_url)?;
556        self.fetch(joined_url.path()).await
557    }
558    pub async fn delete_url(
559        self,
560        link: &Link,
561        template_values: &HashMap<String, String>,
562    ) -> Result<Value, PactBrokerError> {
563        debug!(
564            "fetch_url(link={:?}, template_values={:?})",
565            link, template_values
566        );
567
568        let link_url = if link.templated {
569            debug!("Link URL is templated");
570            self.parse_link_url(link, template_values)
571        } else {
572            link.href.clone().ok_or_else(|| {
573                PactBrokerError::LinkError(format!(
574                    "Link is malformed, there is no href. URL: '{}', LINK: '{}'",
575                    self.url, link.name
576                ))
577            })
578        }?;
579
580        let base_url = self.url.parse::<Url>()?;
581        debug!("base_url: {}", base_url);
582        debug!("link_url: {}", link_url);
583        let joined_url = base_url.join(&link_url)?;
584        debug!("joined_url: {}", joined_url);
585        self.delete(joined_url.path()).await
586    }
587
588    pub async fn fetch(&self, path: &str) -> Result<Value, PactBrokerError> {
589        info!("Fetching path '{}' from pact broker", path);
590        trace!(%path, broker_url = %self.url, ">> fetch");
591        let url = self.resolve_path(path)?;
592        debug!("Final broker URL: {}", url);
593
594        let mut request_builder = match self.auth {
595            Some(ref auth) => match auth {
596                HttpAuth::User(username, password) => {
597                    self.client.get(url).basic_auth(username, password.clone())
598                }
599                HttpAuth::Token(token) => self.client.get(url).bearer_auth(token),
600                _ => self.client.get(url),
601            },
602            None => self.client.get(url),
603        }
604        .header("accept", "application/hal+json, application/json");
605
606        // Apply custom headers if present
607        request_builder = self.apply_custom_headers(request_builder);
608
609        let response = request_builder.send().await.map_err(|err| {
610            PactBrokerError::IoError(format!(
611                "Failed to access pact broker path '{}' - {}. URL: '{}'",
612                &path, err, &self.url,
613            ))
614        })?;
615
616        self.parse_broker_response(path.to_string(), response).await
617    }
618
619    fn resolve_path(&self, path: &str) -> Result<Url, PactBrokerError> {
620        let broker_url = self.url.parse::<Url>()?;
621        let context_path = broker_url.path();
622        let url = if path.is_empty() {
623            broker_url
624        } else if !context_path.is_empty() && context_path != "/" {
625            if path.starts_with(context_path) {
626                let mut base_url = broker_url.clone();
627                base_url.set_path("/");
628                base_url.join(path)?
629            } else if path.starts_with("/") {
630                let mut base_url = broker_url.clone();
631                base_url.set_path(path);
632                base_url
633            } else {
634                let mut base_url = broker_url.clone();
635                let mut cp = context_path.to_string();
636                cp.push('/');
637                base_url.set_path(cp.as_str());
638                base_url.join(path)?
639            }
640        } else {
641            broker_url.join(path)?
642        };
643        Ok(url)
644    }
645
646    pub async fn delete(self, path: &str) -> Result<Value, PactBrokerError> {
647        info!("Deleting path '{}' from pact broker", path);
648
649        let broker_url = self.url.parse::<Url>()?;
650        let context_path = broker_url.path();
651        let url = if context_path.is_empty().not()
652            && context_path != "/"
653            && path.starts_with(context_path)
654        {
655            let mut base_url = broker_url.clone();
656            base_url.set_path("/");
657            base_url.join(path)?
658        } else {
659            broker_url.join(path)?
660        };
661
662        let request_builder = match self.auth {
663            Some(ref auth) => match auth {
664                HttpAuth::User(username, password) => self
665                    .client
666                    .delete(url)
667                    .basic_auth(username, password.clone()),
668                HttpAuth::Token(token) => self.client.delete(url).bearer_auth(token),
669                _ => self.client.delete(url),
670            },
671            None => self.client.delete(url),
672        }
673        .header("Accept", "application/hal+json");
674
675        let response = request_builder.send().await.map_err(|err| {
676            PactBrokerError::IoError(format!(
677                "Failed to delete pact broker path '{}' - {}. URL: '{}'",
678                &path, err, &self.url,
679            ))
680        })?;
681
682        self.parse_broker_response(path.to_string(), response).await
683    }
684
685    async fn parse_broker_response(
686        &self,
687        path: String,
688        response: reqwest::Response,
689    ) -> Result<Value, PactBrokerError> {
690        let is_json_content_type = json_content_type(&response);
691        let content_type = content_type(&response);
692        let status_code = response.status();
693
694        if status_code.is_success() {
695            if is_json_content_type {
696                response.json::<Value>()
697            .await
698            .map_err(|err| PactBrokerError::ContentError(
699              format!("Did not get a valid HAL response body from pact broker path '{}' - {}. URL: '{}'",
700                      path, err, self.url)
701            ))
702            } else if status_code.as_u16() == 204 {
703                Ok(json!({}))
704            } else {
705                debug!("Request from broker was a success, but the response body was not JSON");
706                Err(PactBrokerError::ContentError(format!(
707                    "Did not get a valid HAL response body from pact broker path '{}', content type is '{}'. URL: '{}'",
708                    path, content_type, self.url
709                )))
710            }
711        } else if status_code.as_u16() == 404 {
712            Err(PactBrokerError::NotFound(format!(
713                "Request to pact broker path '{}' failed: {}. URL: '{}'",
714                path, status_code, self.url
715            )))
716        } else {
717            // Handle any error status code (400, 422, 409, etc.)
718            let body = response.bytes().await.map_err(|_| {
719                PactBrokerError::IoError(format!(
720                    "Failed to download response body for path '{}'. URL: '{}'",
721                    &path, self.url
722                ))
723            })?;
724
725            if is_json_content_type {
726                match serde_json::from_slice::<Value>(&body) {
727                    Ok(json_body) => {
728                        if json_body.get("errors").is_some() || json_body.get("notices").is_some() {
729                            Err(handle_validation_errors(json_body))
730                        } else {
731                            Err(PactBrokerError::IoError(format!(
732                                "Request to pact broker path '{}' failed: {}. Response: {}. URL: '{}'",
733                                path, status_code, json_body, self.url
734                            )))
735                        }
736                    }
737                    Err(_) => {
738                        let body_text = from_utf8(&body)
739                            .map(|b| b.to_string())
740                            .unwrap_or_else(|err| format!("could not read body: {}", err));
741                        error!(
742                            "Request to pact broker path '{}' failed: {}",
743                            path, body_text
744                        );
745                        Err(PactBrokerError::IoError(format!(
746                            "Request to pact broker path '{}' failed: {}. URL: '{}'",
747                            path, status_code, self.url
748                        )))
749                    }
750                }
751            } else {
752                let body_text = from_utf8(&body)
753                    .map(|b| b.to_string())
754                    .unwrap_or_else(|err| format!("could not read body: {}", err));
755                error!(
756                    "Request to pact broker path '{}' failed: {}",
757                    path, body_text
758                );
759                Err(PactBrokerError::IoError(format!(
760                    "Request to pact broker path '{}' failed: {}. URL: '{}'",
761                    path, status_code, self.url
762                )))
763            }
764        }
765    }
766
767    fn parse_link_url(
768        &self,
769        link: &Link,
770        values: &HashMap<String, String>,
771    ) -> Result<String, PactBrokerError> {
772        match link.href {
773            Some(ref href) => {
774                debug!("templated URL = {}", href);
775                let re = Regex::new(r"\{(\w+)}").unwrap();
776                let final_url = re.replace_all(href, |caps: &Captures| {
777                    let lookup = caps.get(1).unwrap().as_str();
778                    trace!("Looking up value for key '{}'", lookup);
779                    match values.get(lookup) {
780                        Some(val) => urlencoding::encode(val.as_str()).to_string(),
781                        None => {
782                            warn!(
783                                "No value was found for key '{}', mapped values are {:?}",
784                                lookup, values
785                            );
786                            format!("{{{}}}", lookup)
787                        }
788                    }
789                });
790                debug!("final URL = {}", final_url);
791                Ok(final_url.to_string())
792            }
793            None => Err(PactBrokerError::LinkError(format!(
794                "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{}'",
795                self.url, link.name
796            ))),
797        }
798    }
799
800    /// Iterate over all the links by name
801    pub fn iter_links(&self, link: &str) -> Result<Vec<Link>, PactBrokerError> {
802        match self.path_info {
803      None => Err(PactBrokerError::LinkError(format!("No previous resource has been fetched from the pact broker. URL: '{}', LINK: '{}'",
804        self.url, link))),
805      Some(ref json) => match json.get("_links") {
806        Some(json) => match json.get(link) {
807          Some(link_data) => link_data.as_array()
808              .map(|link_data| link_data.iter().map(|link_json| match link_json {
809                Value::Object(data) => Link::from_json(link, data),
810                Value::String(s) => Link { name: link.to_string(), href: Some(s.clone()), templated: false, title: None },
811                _ => Link { name: link.to_string(), href: Some(link_json.to_string()), templated: false, title: None }
812              }).collect())
813              .ok_or_else(|| PactBrokerError::LinkError(format!("Link is malformed, expected an object but got {}. URL: '{}', LINK: '{}'",
814                  link_data, self.url, link))),
815          None => Err(PactBrokerError::LinkError(format!("Link '{}' was not found in the response, only the following links where found: {:?}. URL: '{}', LINK: '{}'",
816            link, json.as_object().unwrap_or(json!({}).as_object().unwrap()).keys().join(", "), self.url, link)))
817        },
818        None => Err(PactBrokerError::LinkError(format!("Expected a HAL+JSON response from the pact broker, but got a response with no '_links'. URL: '{}', LINK: '{}'",
819          self.url, link)))
820      }
821    }
822    }
823
824    pub async fn post_json(
825        &self,
826        url: &str,
827        body: &str,
828        headers: Option<HashMap<String, String>>,
829    ) -> Result<serde_json::Value, PactBrokerError> {
830        trace!("post_json(url='{}', body='{}')", url, body);
831
832        self.send_document(url, body, Method::POST, headers).await
833    }
834
835    pub async fn put_json(
836        &self,
837        url: &str,
838        body: &str,
839        headers: Option<HashMap<String, String>>,
840    ) -> Result<serde_json::Value, PactBrokerError> {
841        trace!("put_json(url='{}', body='{}')", url, body);
842
843        self.send_document(url, body, Method::PUT, headers).await
844    }
845    pub async fn patch_json(
846        &self,
847        url: &str,
848        body: &str,
849        headers: Option<HashMap<String, String>>,
850    ) -> Result<serde_json::Value, PactBrokerError> {
851        trace!("put_json(url='{}', body='{}')", url, body);
852
853        self.send_document(url, body, Method::PATCH, headers).await
854    }
855
856    async fn send_document(
857        &self,
858        url: &str,
859        body: &str,
860        method: Method,
861        headers: Option<HashMap<String, String>>,
862    ) -> Result<Value, PactBrokerError> {
863        let method_type = method.clone();
864        debug!("Sending JSON to {} using {}: {}", url, method, body);
865
866        let base_url = &self.url.parse::<Url>()?;
867        let url = if url.starts_with("/") {
868            base_url.join(url)?
869        } else {
870            let url = url.parse::<Url>()?;
871            base_url.join(url.path())?
872        };
873
874        let request_builder = match self.auth {
875            Some(ref auth) => match auth {
876                HttpAuth::User(username, password) => self
877                    .client
878                    .request(method, url.clone())
879                    .basic_auth(username, password.clone()),
880                HttpAuth::Token(token) => {
881                    self.client.request(method, url.clone()).bearer_auth(token)
882                }
883                _ => self.client.request(method, url.clone()),
884            },
885            None => self.client.request(method, url.clone()),
886        }
887        .header("Accept", "application/hal+json")
888        .body(body.to_string());
889
890        // Add any additional headers if provided
891
892        let request_builder = if let Some(ref headers) = headers {
893            headers
894                .iter()
895                .fold(request_builder, |builder, (key, value)| {
896                    builder.header(key.as_str(), value.as_str())
897                })
898        } else {
899            request_builder
900        };
901
902        let request_builder = if method_type == Method::PATCH {
903            request_builder.header("Content-Type", "application/merge-patch+json")
904        } else {
905            request_builder.header("Content-Type", "application/json")
906        };
907        match request_builder.send().await {
908            Ok(res) => {
909                self.parse_broker_response(url.path().to_string(), res)
910                    .await
911            }
912            Err(err) => Err(PactBrokerError::IoError(format!(
913                "Failed to send JSON to the pact broker URL '{}' - IoError {}",
914                url, err
915            ))),
916        }
917    }
918}
919
920fn handle_validation_errors(body: Value) -> PactBrokerError {
921    match &body {
922        Value::Object(attrs) => {
923            // Extract notices if present
924            let notices: Vec<Notice> = attrs
925                .get("notices")
926                .and_then(|n| n.as_array())
927                .map(|notices_array| {
928                    notices_array
929                        .iter()
930                        .filter_map(|notice| serde_json::from_value::<Notice>(notice.clone()).ok())
931                        .collect()
932                })
933                .unwrap_or_default();
934
935            if let Some(errors) = attrs.get("errors") {
936                let error_messages = match errors {
937                    Value::Array(values) => values.iter().map(json_to_string).collect(),
938                    Value::Object(errors) => errors
939                        .iter()
940                        .map(|(field, errors)| match errors {
941                            Value::String(error) => format!("{}: {}", field, error),
942                            Value::Array(errors) => format!(
943                                "{}: {}",
944                                field,
945                                errors.iter().map(json_to_string).join(", ")
946                            ),
947                            _ => format!("{}: {}", field, errors),
948                        })
949                        .collect(),
950                    Value::String(s) => vec![s.clone()],
951                    _ => vec![errors.to_string()],
952                };
953
954                if !notices.is_empty() {
955                    PactBrokerError::ValidationErrorWithNotices(error_messages, notices)
956                } else {
957                    PactBrokerError::ValidationError(error_messages)
958                }
959            } else if !notices.is_empty() {
960                // Even if there are no explicit errors, notices might contain error information
961                let notice_messages = notices.iter().map(|n| n.text.clone()).collect();
962                PactBrokerError::ValidationErrorWithNotices(notice_messages, notices)
963            } else {
964                PactBrokerError::ValidationError(vec![body.to_string()])
965            }
966        }
967        Value::String(s) => PactBrokerError::ValidationError(vec![s.clone()]),
968        _ => PactBrokerError::ValidationError(vec![body.to_string()]),
969    }
970}
971
972impl HALClient {
973    /// Builds the reqwest-middleware client stack for a given retry count and SSL
974    /// configuration.
975    ///
976    /// The middleware chain is (outermost → innermost):
977    /// 1. [`TracingMiddleware`] — adds OpenTelemetry trace context to every request.
978    /// 2. [`OtelPropagatorMiddleware`] — injects baggage / W3C trace propagation headers.
979    /// 3. [`RetryMiddleware`] — retries transient 5xx / 408 / 429 failures, honouring
980    ///    any `Retry-After` header present on the response.
981    ///
982    /// # Arguments
983    ///
984    /// * `retries` - Maximum number of total attempts (including the first send).
985    /// * `ssl_options` - TLS configuration (custom CA cert, skip-verify, …).
986    ///
987    /// # Returns
988    ///
989    /// A fully configured [`ClientWithMiddleware`] ready for use.
990    fn build_middleware_client(retries: u8, ssl_options: &SslOptions) -> ClientWithMiddleware {
991        let mut builder = reqwest::Client::builder().user_agent(format!(
992            "{}/{}",
993            env!("CARGO_PKG_NAME"),
994            env!("CARGO_PKG_VERSION")
995        ));
996
997        debug!("Using ssl_options: {:?}", ssl_options);
998        if let Some(ref path) = ssl_options.ssl_cert_path {
999            if let Ok(cert_bytes) = std::fs::read(path) {
1000                match reqwest::Certificate::from_pem_bundle(&cert_bytes) {
1001                    Ok(certs) => {
1002                        debug!("Adding SSL certificate from path: {}", path);
1003                        if ssl_options.use_root_trust_store {
1004                            // Merge custom cert into the native root store.
1005                            builder = builder.tls_certs_merge(certs);
1006                        } else {
1007                            // Use ONLY the provided certificate; bypass all built-in roots.
1008                            debug!(
1009                                "Disabling root trust store for SSL — using only the provided certificate"
1010                            );
1011                            builder = builder.tls_certs_only(certs);
1012                        }
1013                    }
1014                    Err(err) => {
1015                        debug!(
1016                            "Could not parse SSL certificate from path {}: {}",
1017                            path, err
1018                        );
1019                    }
1020                }
1021            } else {
1022                debug!(
1023                    "Could not read SSL certificate from provided path: {}",
1024                    path
1025                );
1026            }
1027        } else if !ssl_options.use_root_trust_store {
1028            debug!(
1029                "ssl-trust-store disabled but no custom certificate provided; proceeding with system roots"
1030            );
1031        }
1032        if ssl_options.skip_ssl {
1033            builder = builder.danger_accept_invalid_certs(true);
1034            debug!("Skipping SSL certificate validation");
1035        }
1036
1037        let built_client = builder.build().expect("failed to build reqwest client");
1038        ClientBuilder::new(built_client)
1039            .with(TracingMiddleware::default())
1040            .with(OtelPropagatorMiddleware)
1041            .with(RetryMiddleware {
1042                max_attempts: retries,
1043            })
1044            .build()
1045    }
1046
1047    pub fn setup(url: &str, auth: Option<HttpAuth>, ssl_options: SslOptions) -> HALClient {
1048        let retries = std::env::var("PACT_BROKER_HTTP_RETRIES")
1049            .ok()
1050            .and_then(|v| v.parse::<u8>().ok())
1051            .unwrap_or(8);
1052
1053        let client = Self::build_middleware_client(retries, &ssl_options);
1054
1055        HALClient {
1056            client,
1057            url: url.to_string(),
1058            path_info: None,
1059            auth,
1060            custom_headers: None,
1061            retries,
1062            ssl_options,
1063        }
1064    }
1065
1066    /// Sets the number of HTTP request retry attempts, overriding the default (3) or any
1067    /// value read from the `PACT_BROKER_HTTP_RETRIES` environment variable.
1068    ///
1069    /// CLI command handlers call this after construction to apply the `--retries` flag value.
1070    /// This rebuilds the internal HTTP client so the new retry count takes effect immediately.
1071    pub fn with_retry_count(mut self, retries: u8) -> Self {
1072        self.retries = retries;
1073        self.client = Self::build_middleware_client(retries, &self.ssl_options);
1074        self
1075    }
1076}
1077
1078pub fn links_from_json(json: &Value) -> Vec<Link> {
1079    match json.get("_links") {
1080        Some(Value::Object(v)) => v
1081            .iter()
1082            .map(|(link, json)| match json {
1083                Value::Object(attr) => Link::from_json(link, attr),
1084                _ => Link {
1085                    name: link.clone(),
1086                    ..Link::default()
1087                },
1088            })
1089            .collect(),
1090        _ => vec![],
1091    }
1092}
1093
1094/// Fetches the pacts from the broker that match the provider name
1095pub async fn fetch_pacts_from_broker(
1096    broker_url: &str,
1097    provider_name: &str,
1098    auth: Option<HttpAuth>,
1099    ssl_options: SslOptions,
1100    custom_headers: Option<CustomHeaders>,
1101) -> anyhow::Result<
1102    Vec<
1103        anyhow::Result<(
1104            Box<dyn Pact + Send + Sync + RefUnwindSafe>,
1105            Option<PactVerificationContext>,
1106            Vec<Link>,
1107        )>,
1108    >,
1109> {
1110    trace!(
1111        "fetch_pacts_from_broker(broker_url='{}', provider_name='{}', auth={})",
1112        broker_url,
1113        provider_name,
1114        auth.clone().unwrap_or_default()
1115    );
1116
1117    let mut hal_client = HALClient::with_url(broker_url, auth, ssl_options, custom_headers);
1118    let template_values = hashmap! { "provider".to_string() => provider_name.to_string() };
1119
1120    hal_client = hal_client
1121        .navigate("pb:latest-provider-pacts", &template_values)
1122        .await
1123        .map_err(move |err| match err {
1124            PactBrokerError::NotFound(_) => PactBrokerError::NotFound(format!(
1125                "No pacts for provider '{}' where found in the pact broker. URL: '{}'",
1126                provider_name, broker_url
1127            )),
1128            _ => err,
1129        })?;
1130
1131    let pact_links = hal_client.clone().iter_links("pacts")?;
1132
1133    let results: Vec<_> = futures::stream::iter(pact_links)
1134        .map(|ref pact_link| {
1135          match pact_link.href {
1136            Some(_) => Ok((hal_client.clone(), pact_link.clone())),
1137            None => Err(
1138              PactBrokerError::LinkError(
1139                format!(
1140                  "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{:?}'",
1141                  &hal_client.url,
1142                  pact_link
1143                )
1144              )
1145            )
1146          }
1147        })
1148        .and_then(|(hal_client, pact_link)| async {
1149          let pact_json = hal_client.fetch_url(
1150            &pact_link.clone(),
1151            &template_values.clone()
1152          ).await?;
1153          Ok((pact_link, pact_json))
1154        })
1155        .map(|result| {
1156          match result {
1157            Ok((pact_link, pact_json)) => {
1158              let href = pact_link.href.unwrap_or_default();
1159              let links = links_from_json(&pact_json);
1160              load_pact_from_json(href.as_str(), &pact_json)
1161                .map(|pact| (pact, None, links))
1162            },
1163            Err(err) => Err(err.into())
1164          }
1165        })
1166        .into_stream()
1167        .collect()
1168        .await;
1169
1170    Ok(results)
1171}
1172
1173/// Fetch Pacts from the broker using the "provider-pacts-for-verification" endpoint
1174#[allow(clippy::too_many_arguments)]
1175pub async fn fetch_pacts_dynamically_from_broker(
1176    broker_url: &str,
1177    provider_name: String,
1178    pending: bool,
1179    include_wip_pacts_since: Option<String>,
1180    provider_tags: Vec<String>,
1181    provider_branch: Option<String>,
1182    consumer_version_selectors: Vec<ConsumerVersionSelector>,
1183    auth: Option<HttpAuth>,
1184    ssl_options: SslOptions,
1185    headers: Option<HashMap<String, String>>,
1186    custom_headers: Option<CustomHeaders>,
1187) -> anyhow::Result<
1188    Vec<
1189        Result<
1190            (
1191                Box<dyn Pact + Send + Sync + RefUnwindSafe>,
1192                Option<PactVerificationContext>,
1193                Vec<Link>,
1194            ),
1195            PactBrokerError,
1196        >,
1197    >,
1198> {
1199    trace!(
1200        "fetch_pacts_dynamically_from_broker(broker_url='{}', provider_name='{}', pending={}, \
1201    include_wip_pacts_since={:?}, provider_tags: {:?}, consumer_version_selectors: {:?}, auth={})",
1202        broker_url,
1203        provider_name,
1204        pending,
1205        include_wip_pacts_since,
1206        provider_tags,
1207        consumer_version_selectors,
1208        auth.clone().unwrap_or_default()
1209    );
1210
1211    let mut hal_client = HALClient::with_url(broker_url, auth, ssl_options, custom_headers);
1212    let template_values = hashmap! { "provider".to_string() => provider_name.clone() };
1213
1214    hal_client = hal_client
1215        .navigate("pb:provider-pacts-for-verification", &template_values)
1216        .await
1217        .map_err(move |err| match err {
1218            PactBrokerError::NotFound(_) => PactBrokerError::NotFound(format!(
1219                "No pacts for provider '{}' were found in the pact broker. URL: '{}'",
1220                provider_name.clone(),
1221                broker_url
1222            )),
1223            _ => err,
1224        })?;
1225
1226    // Construct the Pacts for verification payload
1227    let pacts_for_verification = PactsForVerificationRequest {
1228        provider_version_tags: provider_tags,
1229        provider_version_branch: provider_branch,
1230        include_wip_pacts_since,
1231        consumer_version_selectors,
1232        include_pending_status: pending,
1233    };
1234    let request_body = serde_json::to_string(&pacts_for_verification).unwrap();
1235
1236    // Post the verification request
1237    let response = match hal_client.find_link("self") {
1238        Ok(link) => {
1239            let link = hal_client.clone().parse_link_url(&link, &hashmap! {})?;
1240            match hal_client
1241                .clone()
1242                .post_json(link.as_str(), request_body.as_str(), headers)
1243                .await
1244            {
1245                Ok(res) => Some(res),
1246                Err(err) => {
1247                    info!("error response for pacts for verification: {} ", err);
1248                    return Err(anyhow!(err));
1249                }
1250            }
1251        }
1252        Err(e) => return Err(anyhow!(e)),
1253    };
1254
1255    // Find all of the Pact links
1256    let pact_links = match response {
1257        Some(v) => {
1258            let pfv: PactsForVerificationResponse = serde_json::from_value(v)
1259                .map_err(|err| {
1260                    trace!(
1261                        "Failed to deserialise PactsForVerificationResponse: {}",
1262                        err
1263                    );
1264                    err
1265                })
1266                .unwrap_or(PactsForVerificationResponse {
1267                    embedded: PactsForVerificationBody { pacts: vec![] },
1268                });
1269            trace!(?pfv, "got pacts for verification response");
1270
1271            if pfv.embedded.pacts.is_empty() {
1272                return Err(anyhow!(PactBrokerError::NotFound(
1273                    "No pacts were found for this provider".to_string()
1274                )));
1275            };
1276
1277            let links: Result<Vec<(Link, PactVerificationContext)>, PactBrokerError> = pfv.embedded.pacts.iter().map(| p| {
1278          match p.links.get("self") {
1279            Some(l) => Ok((l.clone(), p.into())),
1280            None => Err(
1281              PactBrokerError::LinkError(
1282                format!(
1283                  "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', PATH: '{:?}'",
1284                  &hal_client.url,
1285                  &p.links,
1286                )
1287              )
1288            )
1289          }
1290        }).collect();
1291
1292            links
1293        }
1294        None => Err(PactBrokerError::NotFound(
1295            "No pacts were found for this provider".to_string(),
1296        )),
1297    }?;
1298
1299    let results: Vec<_> = futures::stream::iter(pact_links)
1300      .map(|(ref pact_link, ref context)| {
1301        match pact_link.href {
1302          Some(_) => Ok((hal_client.clone(), pact_link.clone(), context.clone())),
1303          None => Err(
1304            PactBrokerError::LinkError(
1305              format!(
1306                "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{:?}'",
1307                &hal_client.url,
1308                pact_link
1309              )
1310            )
1311          )
1312        }
1313      })
1314      .and_then(|(hal_client, pact_link, context)| async {
1315        let pact_json = hal_client.fetch_url(
1316          &pact_link.clone(),
1317          &template_values.clone()
1318        ).await?;
1319        Ok((pact_link, pact_json, context))
1320      })
1321      .map(|result| {
1322        match result {
1323          Ok((pact_link, pact_json, context)) => {
1324            let href = pact_link.href.unwrap_or_default();
1325            let links = links_from_json(&pact_json);
1326            load_pact_from_json(href.as_str(), &pact_json)
1327              .map(|pact| (pact, Some(context), links))
1328              .map_err(|err| PactBrokerError::ContentError(format!("{}", err)))
1329          },
1330          Err(err) => Err(err)
1331        }
1332      })
1333      .into_stream()
1334      .collect()
1335      .await;
1336
1337    Ok(results)
1338}
1339
1340/// Fetch the Pact from the given URL, using any required authentication. This will use a GET
1341/// request to the given URL and parse the result into a Pact model. It will also look for any HAL
1342/// links in the response, returning those if found.
1343pub async fn fetch_pact_from_url(
1344    url: &str,
1345    auth: &Option<HttpAuth>,
1346) -> anyhow::Result<(Box<dyn Pact + Send + Sync + RefUnwindSafe>, Vec<Link>)> {
1347    let url = url.to_string();
1348    let auth = auth.clone();
1349    let (url, pact_json) =
1350        tokio::task::spawn_blocking(move || http_utils::fetch_json_from_url(&url, &auth)).await??;
1351    let pact = load_pact_from_json(&url, &pact_json)?;
1352    let links = links_from_json(&pact_json);
1353    Ok((pact, links))
1354}
1355
1356#[skip_serializing_none]
1357#[derive(Serialize, Deserialize, Debug, Clone)]
1358#[serde(rename_all = "camelCase")]
1359/// Structure to represent a HAL link
1360pub struct ConsumerVersionSelector {
1361    /// Application name to filter the results on
1362    pub consumer: Option<String>,
1363    /// Tag
1364    pub tag: Option<String>,
1365    /// Fallback tag if Tag doesn't exist
1366    pub fallback_tag: Option<String>,
1367    /// Only select the latest (if false, this selects all pacts for a tag)
1368    pub latest: Option<bool>,
1369    /// Applications that have been deployed or released
1370    pub deployed_or_released: Option<bool>,
1371    /// Applications that have been deployed
1372    pub deployed: Option<bool>,
1373    /// Applications that have been released
1374    pub released: Option<bool>,
1375    /// Applications in a given environment
1376    pub environment: Option<String>,
1377    /// Applications with the default branch set in the broker
1378    pub main_branch: Option<bool>,
1379    /// Applications with the given branch
1380    pub branch: Option<String>,
1381    /// Applications that match the the provider version branch sent during verification
1382    pub matching_branch: Option<bool>,
1383}
1384
1385#[derive(Serialize, Deserialize, Debug, Clone)]
1386#[serde(rename_all = "camelCase")]
1387struct PactsForVerificationResponse {
1388    #[serde(rename(deserialize = "_embedded"))]
1389    pub embedded: PactsForVerificationBody,
1390}
1391
1392#[derive(Serialize, Deserialize, Debug, Clone)]
1393#[serde(rename_all = "camelCase")]
1394struct PactsForVerificationBody {
1395    pub pacts: Vec<PactForVerification>,
1396}
1397
1398#[derive(Serialize, Deserialize, Debug, Clone)]
1399#[serde(rename_all = "camelCase")]
1400struct PactForVerification {
1401    pub short_description: String,
1402    #[serde(rename(deserialize = "_links"))]
1403    pub links: HashMap<String, Link>,
1404    pub verification_properties: Option<PactVerificationProperties>,
1405}
1406
1407#[skip_serializing_none]
1408#[derive(Serialize, Deserialize, Debug, Clone)]
1409#[serde(rename_all = "camelCase")]
1410/// Request to send to determine the pacts to verify
1411pub struct PactsForVerificationRequest {
1412    /// Provider tags to use for determining pending pacts (if enabled)
1413    #[serde(skip_serializing_if = "Vec::is_empty")]
1414    pub provider_version_tags: Vec<String>,
1415    /// Enable pending pacts feature
1416    pub include_pending_status: bool,
1417    /// Find WIP pacts after given date
1418    pub include_wip_pacts_since: Option<String>,
1419    /// Detailed pact selection criteria , see https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/
1420    pub consumer_version_selectors: Vec<ConsumerVersionSelector>,
1421    /// Current provider version branch if used (instead of tags)
1422    pub provider_version_branch: Option<String>,
1423}
1424
1425#[skip_serializing_none]
1426#[derive(Serialize, Deserialize, Debug, Clone)]
1427#[serde(rename_all = "camelCase")]
1428/// Provides the context on why a Pact was included
1429pub struct PactVerificationContext {
1430    /// Description
1431    pub short_description: String,
1432    /// Properties
1433    pub verification_properties: PactVerificationProperties,
1434}
1435
1436impl From<&PactForVerification> for PactVerificationContext {
1437    fn from(value: &PactForVerification) -> Self {
1438        PactVerificationContext {
1439            short_description: value.short_description.clone(),
1440            verification_properties: value.verification_properties.clone().unwrap_or_default(),
1441        }
1442    }
1443}
1444
1445#[skip_serializing_none]
1446#[derive(Serialize, Deserialize, Debug, Clone, Default)]
1447#[serde(rename_all = "camelCase")]
1448/// Properties associated with the verification context
1449pub struct PactVerificationProperties {
1450    #[serde(default)]
1451    /// If the Pact is pending
1452    pub pending: bool,
1453    /// Notices provided by the Pact Broker
1454    pub notices: Vec<HashMap<String, String>>,
1455}
1456
1457#[cfg(test)]
1458mod hal_client_custom_headers_tests {
1459    use super::*;
1460    use crate::cli::pact_broker::main::types::SslOptions;
1461    use std::collections::HashMap;
1462
1463    fn create_test_custom_headers() -> CustomHeaders {
1464        let mut headers = HashMap::new();
1465        headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
1466        headers.insert("X-API-Key".to_string(), "secret-key".to_string());
1467        CustomHeaders { headers }
1468    }
1469
1470    fn create_cloudflare_custom_headers() -> CustomHeaders {
1471        let mut headers = HashMap::new();
1472        headers.insert(
1473            "CF-Access-Client-Id".to_string(),
1474            "client-id-123".to_string(),
1475        );
1476        headers.insert(
1477            "CF-Access-Client-Secret".to_string(),
1478            "secret-456".to_string(),
1479        );
1480        CustomHeaders { headers }
1481    }
1482
1483    #[test]
1484    fn test_hal_client_with_custom_headers() {
1485        let custom_headers = Some(create_test_custom_headers());
1486        let ssl_options = SslOptions::default();
1487
1488        let client = HALClient::with_url(
1489            "https://test.example.com",
1490            None,
1491            ssl_options,
1492            custom_headers.clone(),
1493        );
1494
1495        assert_eq!(client.url, "https://test.example.com");
1496        assert!(client.custom_headers.is_some());
1497
1498        let headers = client.custom_headers.unwrap();
1499        assert_eq!(headers.headers.len(), 2);
1500        assert_eq!(
1501            headers.headers.get("Authorization"),
1502            Some(&"Bearer test-token".to_string())
1503        );
1504        assert_eq!(
1505            headers.headers.get("X-API-Key"),
1506            Some(&"secret-key".to_string())
1507        );
1508    }
1509
1510    #[test]
1511    fn test_hal_client_with_cloudflare_headers() {
1512        let custom_headers = Some(create_cloudflare_custom_headers());
1513        let ssl_options = SslOptions::default();
1514
1515        let client = HALClient::with_url(
1516            "https://pact-broker.example.com",
1517            None,
1518            ssl_options,
1519            custom_headers.clone(),
1520        );
1521
1522        assert!(client.custom_headers.is_some());
1523
1524        let headers = client.custom_headers.unwrap();
1525        assert_eq!(headers.headers.len(), 2);
1526        assert_eq!(
1527            headers.headers.get("CF-Access-Client-Id"),
1528            Some(&"client-id-123".to_string())
1529        );
1530        assert_eq!(
1531            headers.headers.get("CF-Access-Client-Secret"),
1532            Some(&"secret-456".to_string())
1533        );
1534    }
1535
1536    #[test]
1537    fn test_hal_client_without_custom_headers() {
1538        let ssl_options = SslOptions::default();
1539
1540        let client = HALClient::with_url("https://test.example.com", None, ssl_options, None);
1541
1542        assert!(client.custom_headers.is_none());
1543    }
1544
1545    #[test]
1546    fn test_hal_client_with_auth_and_custom_headers() {
1547        let auth = Some(HttpAuth::Token("bearer-token".to_string()));
1548        let custom_headers = Some(create_test_custom_headers());
1549        let ssl_options = SslOptions::default();
1550
1551        let client = HALClient::with_url(
1552            "https://test.example.com",
1553            auth.clone(),
1554            ssl_options,
1555            custom_headers,
1556        );
1557
1558        assert!(client.auth.is_some());
1559        assert!(client.custom_headers.is_some());
1560
1561        if let Some(HttpAuth::Token(token)) = client.auth {
1562            assert_eq!(token, "bearer-token");
1563        }
1564    }
1565
1566    #[test]
1567    fn test_apply_custom_headers_with_mock_request() {
1568        use reqwest::Client;
1569        use reqwest_middleware::ClientBuilder;
1570
1571        let custom_headers = Some(create_test_custom_headers());
1572        let ssl_options = SslOptions::default();
1573
1574        let client = HALClient::with_url(
1575            "https://test.example.com",
1576            None,
1577            ssl_options,
1578            custom_headers,
1579        );
1580
1581        // Create a mock request builder to test header application
1582        let reqwest_client = Client::new();
1583        let middleware_client = ClientBuilder::new(reqwest_client).build();
1584        let request_builder = middleware_client.get("https://test.example.com/test");
1585
1586        // Apply custom headers
1587        let modified_builder = client.apply_custom_headers(request_builder);
1588
1589        // Build the request to inspect headers
1590        let request = modified_builder.build().unwrap();
1591
1592        // Check that custom headers were applied
1593        assert!(request.headers().contains_key("authorization"));
1594        assert!(request.headers().contains_key("x-api-key"));
1595
1596        assert_eq!(
1597            request
1598                .headers()
1599                .get("authorization")
1600                .unwrap()
1601                .to_str()
1602                .unwrap(),
1603            "Bearer test-token"
1604        );
1605        assert_eq!(
1606            request
1607                .headers()
1608                .get("x-api-key")
1609                .unwrap()
1610                .to_str()
1611                .unwrap(),
1612            "secret-key"
1613        );
1614    }
1615
1616    #[test]
1617    fn test_apply_custom_headers_without_headers() {
1618        use reqwest::Client;
1619        use reqwest_middleware::ClientBuilder;
1620
1621        let ssl_options = SslOptions::default();
1622
1623        let client = HALClient::with_url("https://test.example.com", None, ssl_options, None);
1624
1625        // Create a mock request builder
1626        let reqwest_client = Client::new();
1627        let middleware_client = ClientBuilder::new(reqwest_client).build();
1628        let request_builder = middleware_client.get("https://test.example.com/test");
1629
1630        // Apply custom headers (should be no-op)
1631        let modified_builder = client.apply_custom_headers(request_builder);
1632
1633        // Build the request to inspect headers
1634        let request = modified_builder.build().unwrap();
1635
1636        // Should not contain our test headers
1637        assert!(!request.headers().contains_key("authorization"));
1638        assert!(!request.headers().contains_key("x-api-key"));
1639    }
1640
1641    #[test]
1642    fn test_custom_headers_struct_creation() {
1643        let mut headers = HashMap::new();
1644        headers.insert("Test-Header".to_string(), "test-value".to_string());
1645
1646        let custom_headers = CustomHeaders { headers };
1647
1648        assert_eq!(custom_headers.headers.len(), 1);
1649        assert_eq!(
1650            custom_headers.headers.get("Test-Header"),
1651            Some(&"test-value".to_string())
1652        );
1653    }
1654
1655    #[test]
1656    fn test_custom_headers_empty() {
1657        let headers = HashMap::new();
1658        let custom_headers = CustomHeaders { headers };
1659
1660        assert_eq!(custom_headers.headers.len(), 0);
1661        assert!(custom_headers.headers.is_empty());
1662    }
1663
1664    #[test]
1665    fn test_custom_headers_case_sensitivity() {
1666        let mut headers = HashMap::new();
1667        headers.insert("content-type".to_string(), "application/json".to_string());
1668        headers.insert("Content-Type".to_string(), "text/plain".to_string());
1669
1670        let custom_headers = CustomHeaders { headers };
1671
1672        // Both should exist as separate entries (case sensitive keys)
1673        assert_eq!(custom_headers.headers.len(), 2);
1674        assert_eq!(
1675            custom_headers.headers.get("content-type"),
1676            Some(&"application/json".to_string())
1677        );
1678        assert_eq!(
1679            custom_headers.headers.get("Content-Type"),
1680            Some(&"text/plain".to_string())
1681        );
1682    }
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687    use expectest::expect;
1688    use expectest::prelude::*;
1689
1690    use pact_consumer::prelude::*;
1691
1692    use super::*;
1693
1694    #[test]
1695    fn resolve_path_test() {
1696        let client = HALClient::with_url("not a URL", None, SslOptions::default(), None);
1697        expect!(client.resolve_path("/any")).to(be_err());
1698
1699        let client = HALClient::with_url(
1700            "http://localhost-ip4:1234",
1701            None,
1702            SslOptions::default(),
1703            None,
1704        );
1705        expect!(client.resolve_path(""))
1706            .to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1707        expect!(client.resolve_path("/"))
1708            .to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1709        expect!(client.resolve_path("/any"))
1710            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1711        expect!(client.resolve_path("any"))
1712            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1713        expect!(client.resolve_path("any/sub-path"))
1714            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/any/sub-path").unwrap()));
1715        expect!(client.resolve_path("/base-path"))
1716            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1717        expect!(client.resolve_path("/base-path/"))
1718            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/").unwrap()));
1719        expect!(client.resolve_path("/base-path/sub-path"))
1720            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/sub-path").unwrap()));
1721
1722        let client = HALClient::with_url(
1723            "http://localhost-ip4:1234/base-path",
1724            None,
1725            SslOptions::default(),
1726            None,
1727        );
1728        expect!(client.resolve_path(""))
1729            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1730        expect!(client.resolve_path("/"))
1731            .to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1732        expect!(client.resolve_path("/any"))
1733            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1734        expect!(client.resolve_path("any"))
1735            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/any").unwrap()));
1736        expect!(client.resolve_path("any/sub-path"))
1737            .to(be_ok()
1738                .value(Url::parse("http://localhost-ip4:1234/base-path/any/sub-path").unwrap()));
1739        expect!(client.resolve_path("/base-path"))
1740            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1741        expect!(client.resolve_path("/base-path/"))
1742            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/").unwrap()));
1743        expect!(client.resolve_path("/base-path/sub-path"))
1744            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/sub-path").unwrap()));
1745    }
1746
1747    #[test_log::test(tokio::test)]
1748    async fn navigate_first_retrieves_the_root_resource() {
1749        let pact_broker =
1750            PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1751                .interaction("a request to a hal resource", "", |mut i| async move {
1752                    i.request.path("/");
1753                    i.response
1754          .header("Content-Type", "application/hal+json")
1755          .body("{\"_links\":{\"next\":{\"href\":\"/abc\"},\"prev\":{\"href\":\"/def\"}}}");
1756                    i
1757                })
1758                .await
1759                .interaction("a request to next", "", |mut i| async move {
1760                    i.request.path("/abc");
1761                    i.response
1762                        .header("Content-Type", "application/json")
1763                        .json_body(json_pattern!("Yay! You found your way here"));
1764                    i
1765                })
1766                .await
1767                .start_mock_server(None, Some(MockServerConfig::default()));
1768
1769        let client = HALClient::with_url(
1770            pact_broker.url().as_str(),
1771            None,
1772            SslOptions::default(),
1773            None,
1774        );
1775        let result = client.navigate("next", &hashmap! {}).await.unwrap();
1776        expect!(result.path_info).to(be_some().value(serde_json::Value::String(
1777            "Yay! You found your way here".to_string(),
1778        )));
1779    }
1780
1781    #[test_log::test(tokio::test)]
1782    async fn navigate_will_not_retrieve_the_root_resource_if_a_path_is_already_set() {
1783        let pact_broker = PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1784            .interaction("a request to next", "", |mut i| async move {
1785                i.request.path("/abc");
1786                i.response
1787                    .header("Content-Type", "application/json")
1788                    .json_body(json_pattern!("Yay! You found your way here"));
1789                i
1790            })
1791            .await
1792            .start_mock_server(None, Some(MockServerConfig::default()));
1793
1794        let mut client = HALClient::with_url(
1795            pact_broker.url().as_str(),
1796            None,
1797            SslOptions::default(),
1798            None,
1799        );
1800        client.path_info = Some(json!({
1801          "_links": {
1802            "next": { "href": "/abc" },
1803            "prev": { "href": "/def" }
1804          }
1805        }));
1806        let result = client.navigate("next", &hashmap! {}).await.unwrap();
1807        expect!(result.path_info).to(be_some().value(serde_json::Value::String(
1808            "Yay! You found your way here".to_string(),
1809        )));
1810    }
1811
1812    #[test_log::test(tokio::test)]
1813    async fn navigate_takes_context_paths_into_account() {
1814        let pact_broker = PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1815      .interaction("a request to a hal resource with base path", "", |mut i| async move {
1816        i.request.path("/base-path");
1817        i.response
1818          .header("Content-Type", "application/hal+json")
1819          .body("{\"_links\":{\"next\":{\"href\":\"/base-path/abc\"},\"prev\":{\"href\":\"/base-path/def\"}}}");
1820        i
1821      })
1822      .await
1823      .interaction("a request to next with a base path", "", |mut i| async move {
1824        i.request.path("/base-path/abc");
1825        i.response
1826          .header("Content-Type", "application/json")
1827          .json_body(json_pattern!("Yay! You found your way here"));
1828        i
1829      })
1830      .await
1831      .start_mock_server(None, Some(MockServerConfig::default()));
1832
1833        let client = HALClient::with_url(
1834            pact_broker.url().join("/base-path").unwrap().as_str(),
1835            None,
1836            SslOptions::default(),
1837            None,
1838        );
1839        let result = client.navigate("next", &hashmap! {}).await.unwrap();
1840        expect!(result.path_info).to(be_some().value(serde_json::Value::String(
1841            "Yay! You found your way here".to_string(),
1842        )));
1843    }
1844}
1845
1846// MARK: RetryMiddleware integration tests
1847
1848#[cfg(test)]
1849mod retry_middleware_tests {
1850    use std::sync::Arc;
1851    use std::sync::atomic::{AtomicUsize, Ordering};
1852
1853    use axum::{Router, body::Body, http::StatusCode, response::Response, routing::get};
1854    use tokio::net::TcpListener;
1855
1856    use super::{HALClient, SslOptions};
1857
1858    async fn spawn_test_server(router: Router) -> String {
1859        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1860        let addr = listener.local_addr().unwrap();
1861        tokio::spawn(async move {
1862            axum::serve(listener, router).await.unwrap();
1863        });
1864        format!("http://{}", addr)
1865    }
1866
1867    fn hal_client(base_url: &str, retries: u8) -> HALClient {
1868        HALClient::with_url(base_url, None, SslOptions::default(), None).with_retry_count(retries)
1869    }
1870
1871    #[tokio::test]
1872    async fn retries_on_429_too_many_requests() {
1873        let request_count = Arc::new(AtomicUsize::new(0));
1874        let count = request_count.clone();
1875
1876        let router = Router::new().route(
1877            "/",
1878            get(move || {
1879                let count = count.clone();
1880                async move {
1881                    let n = count.fetch_add(1, Ordering::SeqCst);
1882                    if n < 2 {
1883                        Response::builder()
1884                            .status(StatusCode::TOO_MANY_REQUESTS)
1885                            .body(Body::from("{\"_links\":{}}"))
1886                            .unwrap()
1887                    } else {
1888                        Response::builder()
1889                            .status(StatusCode::OK)
1890                            .header("content-type", "application/hal+json")
1891                            .body(Body::from("{\"_links\":{}}"))
1892                            .unwrap()
1893                    }
1894                }
1895            }),
1896        );
1897
1898        let base_url = spawn_test_server(router).await;
1899        let client = hal_client(&base_url, 3);
1900        let result = client.fetch("").await;
1901
1902        assert!(result.is_ok(), "expected OK but got: {:?}", result.err());
1903        assert_eq!(request_count.load(Ordering::SeqCst), 3);
1904    }
1905
1906    #[tokio::test]
1907    async fn does_not_retry_404_not_found() {
1908        let request_count = Arc::new(AtomicUsize::new(0));
1909        let count = request_count.clone();
1910
1911        let router = Router::new().route(
1912            "/",
1913            get(move || {
1914                let count = count.clone();
1915                async move {
1916                    count.fetch_add(1, Ordering::SeqCst);
1917                    StatusCode::NOT_FOUND
1918                }
1919            }),
1920        );
1921
1922        let base_url = spawn_test_server(router).await;
1923        let client = hal_client(&base_url, 3);
1924        let _ = client.fetch("").await;
1925
1926        assert_eq!(
1927            request_count.load(Ordering::SeqCst),
1928            1,
1929            "404 should not be retried"
1930        );
1931    }
1932
1933    #[tokio::test]
1934    async fn retries_on_500_internal_server_error() {
1935        let request_count = Arc::new(AtomicUsize::new(0));
1936        let count = request_count.clone();
1937
1938        let router = Router::new().route(
1939            "/",
1940            get(move || {
1941                let count = count.clone();
1942                async move {
1943                    let n = count.fetch_add(1, Ordering::SeqCst);
1944                    if n == 0 {
1945                        StatusCode::INTERNAL_SERVER_ERROR
1946                    } else {
1947                        StatusCode::OK
1948                    }
1949                }
1950            }),
1951        );
1952
1953        let base_url = spawn_test_server(router).await;
1954        let client = hal_client(&base_url, 3);
1955        let _ = client.fetch("").await;
1956
1957        assert_eq!(
1958            request_count.load(Ordering::SeqCst),
1959            2,
1960            "500 should be retried once"
1961        );
1962    }
1963
1964    #[tokio::test]
1965    async fn honours_integer_retry_after_header() {
1966        // Retry-After: 0 exercises the header path without real delay.
1967        let request_count = Arc::new(AtomicUsize::new(0));
1968        let count = request_count.clone();
1969
1970        let router = Router::new().route(
1971            "/",
1972            get(move || {
1973                let count = count.clone();
1974                async move {
1975                    let n = count.fetch_add(1, Ordering::SeqCst);
1976                    if n == 0 {
1977                        Response::builder()
1978                            .status(StatusCode::TOO_MANY_REQUESTS)
1979                            .header("Retry-After", "0")
1980                            .body(Body::from("{\"_links\":{}}"))
1981                            .unwrap()
1982                    } else {
1983                        Response::builder()
1984                            .status(StatusCode::OK)
1985                            .header("content-type", "application/hal+json")
1986                            .body(Body::from("{\"_links\":{}}"))
1987                            .unwrap()
1988                    }
1989                }
1990            }),
1991        );
1992
1993        let base_url = spawn_test_server(router).await;
1994        let client = hal_client(&base_url, 3);
1995        let result = client.fetch("").await;
1996
1997        assert!(result.is_ok());
1998        assert_eq!(request_count.load(Ordering::SeqCst), 2);
1999    }
2000
2001    #[tokio::test]
2002    async fn honours_http_date_retry_after_header() {
2003        // An HTTP-date Retry-After in the past should result in a zero delay,
2004        // confirming that the date form is parsed rather than silently ignored.
2005        let request_count = Arc::new(AtomicUsize::new(0));
2006        let count = request_count.clone();
2007
2008        let router = Router::new().route(
2009            "/",
2010            get(move || {
2011                let count = count.clone();
2012                async move {
2013                    let n = count.fetch_add(1, Ordering::SeqCst);
2014                    if n == 0 {
2015                        Response::builder()
2016                            .status(StatusCode::TOO_MANY_REQUESTS)
2017                            // A date well in the past → delay is immediately 0.
2018                            .header("Retry-After", "Thu, 01 Jan 1970 00:00:00 GMT")
2019                            .body(Body::from("{\"_links\":{}}"))
2020                            .unwrap()
2021                    } else {
2022                        Response::builder()
2023                            .status(StatusCode::OK)
2024                            .header("content-type", "application/hal+json")
2025                            .body(Body::from("{\"_links\":{}}"))
2026                            .unwrap()
2027                    }
2028                }
2029            }),
2030        );
2031
2032        let base_url = spawn_test_server(router).await;
2033        let client = hal_client(&base_url, 3);
2034        let result = client.fetch("").await;
2035
2036        assert!(result.is_ok());
2037        assert_eq!(
2038            request_count.load(Ordering::SeqCst),
2039            2,
2040            "HTTP-date Retry-After should be parsed and retry should happen"
2041        );
2042    }
2043
2044    #[tokio::test]
2045    async fn sends_one_request_when_retries_is_zero() {
2046        let request_count = Arc::new(AtomicUsize::new(0));
2047        let count = request_count.clone();
2048
2049        let router = Router::new().route(
2050            "/",
2051            get(move || {
2052                let count = count.clone();
2053                async move {
2054                    count.fetch_add(1, Ordering::SeqCst);
2055                    Response::builder()
2056                        .status(StatusCode::TOO_MANY_REQUESTS)
2057                        .body(Body::empty())
2058                        .unwrap()
2059                }
2060            }),
2061        );
2062
2063        let base_url = spawn_test_server(router).await;
2064        let client = hal_client(&base_url, 0);
2065        let _ = client.fetch("").await;
2066
2067        assert_eq!(
2068            request_count.load(Ordering::SeqCst),
2069            1,
2070            "retries=0 should send exactly one request"
2071        );
2072    }
2073
2074    #[tokio::test]
2075    async fn returns_last_failure_when_all_retries_exhausted() {
2076        let router = Router::new().route(
2077            "/",
2078            get(|| async {
2079                Response::builder()
2080                    .status(StatusCode::TOO_MANY_REQUESTS)
2081                    .body(Body::empty())
2082                    .unwrap()
2083            }),
2084        );
2085
2086        let base_url = spawn_test_server(router).await;
2087        let client = hal_client(&base_url, 2);
2088        let result = client.fetch("").await;
2089
2090        assert!(
2091            result.is_err(),
2092            "all retries exhausted should return error, got: {:?}",
2093            result
2094        );
2095    }
2096}