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 mut 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        request_builder = self.apply_custom_headers(request_builder);
676        let response = request_builder.send().await.map_err(|err| {
677            PactBrokerError::IoError(format!(
678                "Failed to delete pact broker path '{}' - {}. URL: '{}'",
679                path, err, self.url,
680            ))
681        })?;
682
683        self.parse_broker_response(path.to_string(), response).await
684    }
685
686    async fn parse_broker_response(
687        &self,
688        path: String,
689        response: reqwest::Response,
690    ) -> Result<Value, PactBrokerError> {
691        let is_json_content_type = json_content_type(&response);
692        let content_type = content_type(&response);
693        let status_code = response.status();
694
695        if status_code.is_success() {
696            if is_json_content_type {
697                response.json::<Value>()
698            .await
699            .map_err(|err| PactBrokerError::ContentError(
700              format!("Did not get a valid HAL response body from pact broker path '{}' - {}. URL: '{}'",
701                      path, err, self.url)
702            ))
703            } else if status_code.as_u16() == 204 {
704                Ok(json!({}))
705            } else {
706                debug!("Request from broker was a success, but the response body was not JSON");
707                Err(PactBrokerError::ContentError(format!(
708                    "Did not get a valid HAL response body from pact broker path '{}', content type is '{}'. URL: '{}'",
709                    path, content_type, self.url
710                )))
711            }
712        } else if status_code.as_u16() == 404 {
713            Err(PactBrokerError::NotFound(format!(
714                "Request to pact broker path '{}' failed: {}. URL: '{}'",
715                path, status_code, self.url
716            )))
717        } else {
718            // Handle any error status code (400, 422, 409, etc.)
719            let body = response.bytes().await.map_err(|_| {
720                PactBrokerError::IoError(format!(
721                    "Failed to download response body for path '{}'. URL: '{}'",
722                    path, self.url
723                ))
724            })?;
725
726            if is_json_content_type {
727                match serde_json::from_slice::<Value>(&body) {
728                    Ok(json_body) => {
729                        if json_body.get("errors").is_some() || json_body.get("notices").is_some() {
730                            Err(handle_validation_errors(json_body))
731                        } else {
732                            Err(PactBrokerError::IoError(format!(
733                                "Request to pact broker path '{}' failed: {}. Response: {}. URL: '{}'",
734                                path, status_code, json_body, self.url
735                            )))
736                        }
737                    }
738                    Err(_) => {
739                        let body_text = from_utf8(&body)
740                            .map(|b| b.to_string())
741                            .unwrap_or_else(|err| format!("could not read body: {}", err));
742                        error!(
743                            "Request to pact broker path '{}' failed: {}",
744                            path, body_text
745                        );
746                        Err(PactBrokerError::IoError(format!(
747                            "Request to pact broker path '{}' failed: {}. URL: '{}'",
748                            path, status_code, self.url
749                        )))
750                    }
751                }
752            } else {
753                let body_text = from_utf8(&body)
754                    .map(|b| b.to_string())
755                    .unwrap_or_else(|err| format!("could not read body: {}", err));
756                error!(
757                    "Request to pact broker path '{}' failed: {}",
758                    path, body_text
759                );
760                Err(PactBrokerError::IoError(format!(
761                    "Request to pact broker path '{}' failed: {}. URL: '{}'",
762                    path, status_code, self.url
763                )))
764            }
765        }
766    }
767
768    fn parse_link_url(
769        &self,
770        link: &Link,
771        values: &HashMap<String, String>,
772    ) -> Result<String, PactBrokerError> {
773        match link.href {
774            Some(ref href) => {
775                debug!("templated URL = {}", href);
776                let re = Regex::new(r"\{(\w+)}").unwrap();
777                let final_url = re.replace_all(href, |caps: &Captures| {
778                    let lookup = caps.get(1).unwrap().as_str();
779                    trace!("Looking up value for key '{}'", lookup);
780                    match values.get(lookup) {
781                        Some(val) => urlencoding::encode(val.as_str()).to_string(),
782                        None => {
783                            warn!(
784                                "No value was found for key '{}', mapped values are {:?}",
785                                lookup, values
786                            );
787                            format!("{{{}}}", lookup)
788                        }
789                    }
790                });
791                debug!("final URL = {}", final_url);
792                Ok(final_url.to_string())
793            }
794            None => Err(PactBrokerError::LinkError(format!(
795                "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{}'",
796                self.url, link.name
797            ))),
798        }
799    }
800
801    /// Iterate over all the links by name
802    pub fn iter_links(&self, link: &str) -> Result<Vec<Link>, PactBrokerError> {
803        match self.path_info {
804      None => Err(PactBrokerError::LinkError(format!("No previous resource has been fetched from the pact broker. URL: '{}', LINK: '{}'",
805        self.url, link))),
806      Some(ref json) => match json.get("_links") {
807        Some(json) => match json.get(link) {
808          Some(link_data) => link_data.as_array()
809              .map(|link_data| link_data.iter().map(|link_json| match link_json {
810                Value::Object(data) => Link::from_json(link, data),
811                Value::String(s) => Link { name: link.to_string(), href: Some(s.clone()), templated: false, title: None },
812                _ => Link { name: link.to_string(), href: Some(link_json.to_string()), templated: false, title: None }
813              }).collect())
814              .ok_or_else(|| PactBrokerError::LinkError(format!("Link is malformed, expected an object but got {}. URL: '{}', LINK: '{}'",
815                  link_data, self.url, link))),
816          None => Err(PactBrokerError::LinkError(format!("Link '{}' was not found in the response, only the following links where found: {:?}. URL: '{}', LINK: '{}'",
817            link, json.as_object().unwrap_or(json!({}).as_object().unwrap()).keys().join(", "), self.url, link)))
818        },
819        None => Err(PactBrokerError::LinkError(format!("Expected a HAL+JSON response from the pact broker, but got a response with no '_links'. URL: '{}', LINK: '{}'",
820          self.url, link)))
821      }
822    }
823    }
824
825    pub async fn post_json(
826        &self,
827        url: &str,
828        body: &str,
829        headers: Option<HashMap<String, String>>,
830    ) -> Result<serde_json::Value, PactBrokerError> {
831        trace!("post_json(url='{}', body='{}')", url, body);
832
833        self.send_document(url, body, Method::POST, headers).await
834    }
835
836    pub async fn put_json(
837        &self,
838        url: &str,
839        body: &str,
840        headers: Option<HashMap<String, String>>,
841    ) -> Result<serde_json::Value, PactBrokerError> {
842        trace!("put_json(url='{}', body='{}')", url, body);
843
844        self.send_document(url, body, Method::PUT, headers).await
845    }
846    pub async fn patch_json(
847        &self,
848        url: &str,
849        body: &str,
850        headers: Option<HashMap<String, String>>,
851    ) -> Result<serde_json::Value, PactBrokerError> {
852        trace!("put_json(url='{}', body='{}')", url, body);
853
854        self.send_document(url, body, Method::PATCH, headers).await
855    }
856
857    async fn send_document(
858        &self,
859        url: &str,
860        body: &str,
861        method: Method,
862        headers: Option<HashMap<String, String>>,
863    ) -> Result<Value, PactBrokerError> {
864        let method_type = method.clone();
865        debug!("Sending JSON to {} using {}: {}", url, method, body);
866
867        let base_url = &self.url.parse::<Url>()?;
868        let url = if url.starts_with("/") {
869            base_url.join(url)?
870        } else {
871            let url = url.parse::<Url>()?;
872            base_url.join(url.path())?
873        };
874
875        let request_builder = match self.auth {
876            Some(ref auth) => match auth {
877                HttpAuth::User(username, password) => self
878                    .client
879                    .request(method, url.clone())
880                    .basic_auth(username, password.clone()),
881                HttpAuth::Token(token) => {
882                    self.client.request(method, url.clone()).bearer_auth(token)
883                }
884                _ => self.client.request(method, url.clone()),
885            },
886            None => self.client.request(method, url.clone()),
887        }
888        .header("Accept", "application/hal+json")
889        .body(body.to_string());
890
891        // Add any additional headers if provided
892
893        let mut request_builder = if let Some(ref headers) = headers {
894            headers
895                .iter()
896                .fold(request_builder, |builder, (key, value)| {
897                    builder.header(key.as_str(), value.as_str())
898                })
899        } else {
900            request_builder
901        };
902
903        request_builder = self.apply_custom_headers(request_builder);
904
905        let request_builder = if method_type == Method::PATCH {
906            request_builder.header("Content-Type", "application/merge-patch+json")
907        } else {
908            request_builder.header("Content-Type", "application/json")
909        };
910        match request_builder.send().await {
911            Ok(res) => {
912                self.parse_broker_response(url.path().to_string(), res)
913                    .await
914            }
915            Err(err) => Err(PactBrokerError::IoError(format!(
916                "Failed to send JSON to the pact broker URL '{}' - IoError {}",
917                url, err
918            ))),
919        }
920    }
921}
922
923fn handle_validation_errors(body: Value) -> PactBrokerError {
924    match &body {
925        Value::Object(attrs) => {
926            // Extract notices if present
927            let notices: Vec<Notice> = attrs
928                .get("notices")
929                .and_then(|n| n.as_array())
930                .map(|notices_array| {
931                    notices_array
932                        .iter()
933                        .filter_map(|notice| serde_json::from_value::<Notice>(notice.clone()).ok())
934                        .collect()
935                })
936                .unwrap_or_default();
937
938            if let Some(errors) = attrs.get("errors") {
939                let error_messages = match errors {
940                    Value::Array(values) => values.iter().map(json_to_string).collect(),
941                    Value::Object(errors) => errors
942                        .iter()
943                        .map(|(field, errors)| match errors {
944                            Value::String(error) => format!("{}: {}", field, error),
945                            Value::Array(errors) => format!(
946                                "{}: {}",
947                                field,
948                                errors.iter().map(json_to_string).join(", ")
949                            ),
950                            _ => format!("{}: {}", field, errors),
951                        })
952                        .collect(),
953                    Value::String(s) => vec![s.clone()],
954                    _ => vec![errors.to_string()],
955                };
956
957                if !notices.is_empty() {
958                    PactBrokerError::ValidationErrorWithNotices(error_messages, notices)
959                } else {
960                    PactBrokerError::ValidationError(error_messages)
961                }
962            } else if !notices.is_empty() {
963                // Even if there are no explicit errors, notices might contain error information
964                let notice_messages = notices.iter().map(|n| n.text.clone()).collect();
965                PactBrokerError::ValidationErrorWithNotices(notice_messages, notices)
966            } else {
967                PactBrokerError::ValidationError(vec![body.to_string()])
968            }
969        }
970        Value::String(s) => PactBrokerError::ValidationError(vec![s.clone()]),
971        _ => PactBrokerError::ValidationError(vec![body.to_string()]),
972    }
973}
974
975impl HALClient {
976    /// Builds the reqwest-middleware client stack for a given retry count and SSL
977    /// configuration.
978    ///
979    /// The middleware chain is (outermost → innermost):
980    /// 1. [`TracingMiddleware`] — adds OpenTelemetry trace context to every request.
981    /// 2. [`OtelPropagatorMiddleware`] — injects baggage / W3C trace propagation headers.
982    /// 3. [`RetryMiddleware`] — retries transient 5xx / 408 / 429 failures, honouring
983    ///    any `Retry-After` header present on the response.
984    ///
985    /// # Arguments
986    ///
987    /// * `retries` - Maximum number of total attempts (including the first send).
988    /// * `ssl_options` - TLS configuration (custom CA cert, skip-verify, …).
989    ///
990    /// # Returns
991    ///
992    /// A fully configured [`ClientWithMiddleware`] ready for use.
993    fn build_middleware_client(retries: u8, ssl_options: &SslOptions) -> ClientWithMiddleware {
994        let mut builder = reqwest::Client::builder().user_agent(format!(
995            "{}/{}",
996            env!("CARGO_PKG_NAME"),
997            env!("CARGO_PKG_VERSION")
998        ));
999
1000        debug!("Using ssl_options: {:?}", ssl_options);
1001        if let Some(ref path) = ssl_options.ssl_cert_path {
1002            if let Ok(cert_bytes) = std::fs::read(path) {
1003                match reqwest::Certificate::from_pem_bundle(&cert_bytes) {
1004                    Ok(certs) => {
1005                        debug!("Adding SSL certificate from path: {}", path);
1006                        if ssl_options.use_root_trust_store {
1007                            // Merge custom cert into the native root store.
1008                            builder = builder.tls_certs_merge(certs);
1009                        } else {
1010                            // Use ONLY the provided certificate; bypass all built-in roots.
1011                            debug!(
1012                                "Disabling root trust store for SSL — using only the provided certificate"
1013                            );
1014                            builder = builder.tls_certs_only(certs);
1015                        }
1016                    }
1017                    Err(err) => {
1018                        debug!(
1019                            "Could not parse SSL certificate from path {}: {}",
1020                            path, err
1021                        );
1022                    }
1023                }
1024            } else {
1025                debug!(
1026                    "Could not read SSL certificate from provided path: {}",
1027                    path
1028                );
1029            }
1030        } else if !ssl_options.use_root_trust_store {
1031            debug!(
1032                "ssl-trust-store disabled but no custom certificate provided; proceeding with system roots"
1033            );
1034        }
1035        if ssl_options.skip_ssl {
1036            builder = builder.danger_accept_invalid_certs(true);
1037            debug!("Skipping SSL certificate validation");
1038        }
1039
1040        let built_client = builder.build().expect("failed to build reqwest client");
1041        ClientBuilder::new(built_client)
1042            .with(TracingMiddleware::default())
1043            .with(OtelPropagatorMiddleware)
1044            .with(RetryMiddleware {
1045                max_attempts: retries,
1046            })
1047            .build()
1048    }
1049
1050    pub fn setup(url: &str, auth: Option<HttpAuth>, ssl_options: SslOptions) -> HALClient {
1051        let retries = std::env::var("PACT_BROKER_HTTP_RETRIES")
1052            .ok()
1053            .and_then(|v| v.parse::<u8>().ok())
1054            .unwrap_or(8);
1055
1056        let client = Self::build_middleware_client(retries, &ssl_options);
1057
1058        HALClient {
1059            client,
1060            url: url.to_string(),
1061            path_info: None,
1062            auth,
1063            custom_headers: None,
1064            retries,
1065            ssl_options,
1066        }
1067    }
1068
1069    /// Sets the number of HTTP request retry attempts, overriding the default (3) or any
1070    /// value read from the `PACT_BROKER_HTTP_RETRIES` environment variable.
1071    ///
1072    /// CLI command handlers call this after construction to apply the `--retries` flag value.
1073    /// This rebuilds the internal HTTP client so the new retry count takes effect immediately.
1074    pub fn with_retry_count(mut self, retries: u8) -> Self {
1075        self.retries = retries;
1076        self.client = Self::build_middleware_client(retries, &self.ssl_options);
1077        self
1078    }
1079}
1080
1081pub fn links_from_json(json: &Value) -> Vec<Link> {
1082    match json.get("_links") {
1083        Some(Value::Object(v)) => v
1084            .iter()
1085            .map(|(link, json)| match json {
1086                Value::Object(attr) => Link::from_json(link, attr),
1087                _ => Link {
1088                    name: link.clone(),
1089                    ..Link::default()
1090                },
1091            })
1092            .collect(),
1093        _ => vec![],
1094    }
1095}
1096
1097/// Fetches the pacts from the broker that match the provider name
1098pub async fn fetch_pacts_from_broker(
1099    broker_url: &str,
1100    provider_name: &str,
1101    auth: Option<HttpAuth>,
1102    ssl_options: SslOptions,
1103    custom_headers: Option<CustomHeaders>,
1104) -> anyhow::Result<
1105    Vec<
1106        anyhow::Result<(
1107            Box<dyn Pact + Send + Sync + RefUnwindSafe>,
1108            Option<PactVerificationContext>,
1109            Vec<Link>,
1110        )>,
1111    >,
1112> {
1113    trace!(
1114        "fetch_pacts_from_broker(broker_url='{}', provider_name='{}', auth={})",
1115        broker_url,
1116        provider_name,
1117        auth.clone().unwrap_or_default()
1118    );
1119
1120    let mut hal_client = HALClient::with_url(broker_url, auth, ssl_options, custom_headers);
1121    let template_values = hashmap! { "provider".to_string() => provider_name.to_string() };
1122
1123    hal_client = hal_client
1124        .navigate("pb:latest-provider-pacts", &template_values)
1125        .await
1126        .map_err(move |err| match err {
1127            PactBrokerError::NotFound(_) => PactBrokerError::NotFound(format!(
1128                "No pacts for provider '{}' where found in the pact broker. URL: '{}'",
1129                provider_name, broker_url
1130            )),
1131            _ => err,
1132        })?;
1133
1134    let pact_links = hal_client.clone().iter_links("pacts")?;
1135
1136    let results: Vec<_> = futures::stream::iter(pact_links)
1137        .map(|ref pact_link| {
1138          match pact_link.href {
1139            Some(_) => Ok((hal_client.clone(), pact_link.clone())),
1140            None => Err(
1141              PactBrokerError::LinkError(
1142                format!(
1143                  "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{:?}'",
1144                  hal_client.url,
1145                  pact_link
1146                )
1147              )
1148            )
1149          }
1150        })
1151        .and_then(|(hal_client, pact_link)| async {
1152          let pact_json = hal_client.fetch_url(
1153            &pact_link.clone(),
1154            &template_values.clone()
1155          ).await?;
1156          Ok((pact_link, pact_json))
1157        })
1158        .map(|result| {
1159          match result {
1160            Ok((pact_link, pact_json)) => {
1161              let href = pact_link.href.unwrap_or_default();
1162              let links = links_from_json(&pact_json);
1163              load_pact_from_json(href.as_str(), &pact_json)
1164                .map(|pact| (pact, None, links))
1165            },
1166            Err(err) => Err(err.into())
1167          }
1168        })
1169        .into_stream()
1170        .collect()
1171        .await;
1172
1173    Ok(results)
1174}
1175
1176/// Fetch Pacts from the broker using the "provider-pacts-for-verification" endpoint
1177#[allow(clippy::too_many_arguments)]
1178pub async fn fetch_pacts_dynamically_from_broker(
1179    broker_url: &str,
1180    provider_name: String,
1181    pending: bool,
1182    include_wip_pacts_since: Option<String>,
1183    provider_tags: Vec<String>,
1184    provider_branch: Option<String>,
1185    consumer_version_selectors: Vec<ConsumerVersionSelector>,
1186    auth: Option<HttpAuth>,
1187    ssl_options: SslOptions,
1188    headers: Option<HashMap<String, String>>,
1189    custom_headers: Option<CustomHeaders>,
1190) -> anyhow::Result<
1191    Vec<
1192        Result<
1193            (
1194                Box<dyn Pact + Send + Sync + RefUnwindSafe>,
1195                Option<PactVerificationContext>,
1196                Vec<Link>,
1197            ),
1198            PactBrokerError,
1199        >,
1200    >,
1201> {
1202    trace!(
1203        "fetch_pacts_dynamically_from_broker(broker_url='{}', provider_name='{}', pending={}, \
1204    include_wip_pacts_since={:?}, provider_tags: {:?}, consumer_version_selectors: {:?}, auth={})",
1205        broker_url,
1206        provider_name,
1207        pending,
1208        include_wip_pacts_since,
1209        provider_tags,
1210        consumer_version_selectors,
1211        auth.clone().unwrap_or_default()
1212    );
1213
1214    let mut hal_client = HALClient::with_url(broker_url, auth, ssl_options, custom_headers);
1215    let template_values = hashmap! { "provider".to_string() => provider_name.clone() };
1216
1217    hal_client = hal_client
1218        .navigate("pb:provider-pacts-for-verification", &template_values)
1219        .await
1220        .map_err(move |err| match err {
1221            PactBrokerError::NotFound(_) => PactBrokerError::NotFound(format!(
1222                "No pacts for provider '{}' were found in the pact broker. URL: '{}'",
1223                provider_name.clone(),
1224                broker_url
1225            )),
1226            _ => err,
1227        })?;
1228
1229    // Construct the Pacts for verification payload
1230    let pacts_for_verification = PactsForVerificationRequest {
1231        provider_version_tags: provider_tags,
1232        provider_version_branch: provider_branch,
1233        include_wip_pacts_since,
1234        consumer_version_selectors,
1235        include_pending_status: pending,
1236    };
1237    let request_body = serde_json::to_string(&pacts_for_verification).unwrap();
1238
1239    // Post the verification request
1240    let response = match hal_client.find_link("self") {
1241        Ok(link) => {
1242            let link = hal_client.clone().parse_link_url(&link, &hashmap! {})?;
1243            match hal_client
1244                .clone()
1245                .post_json(link.as_str(), request_body.as_str(), headers)
1246                .await
1247            {
1248                Ok(res) => Some(res),
1249                Err(err) => {
1250                    info!("error response for pacts for verification: {} ", err);
1251                    return Err(anyhow!(err));
1252                }
1253            }
1254        }
1255        Err(e) => return Err(anyhow!(e)),
1256    };
1257
1258    // Find all of the Pact links
1259    let pact_links = match response {
1260        Some(v) => {
1261            let pfv: PactsForVerificationResponse = serde_json::from_value(v)
1262                .map_err(|err| {
1263                    trace!(
1264                        "Failed to deserialise PactsForVerificationResponse: {}",
1265                        err
1266                    );
1267                    err
1268                })
1269                .unwrap_or(PactsForVerificationResponse {
1270                    embedded: PactsForVerificationBody { pacts: vec![] },
1271                });
1272            trace!(?pfv, "got pacts for verification response");
1273
1274            if pfv.embedded.pacts.is_empty() {
1275                return Err(anyhow!(PactBrokerError::NotFound(
1276                    "No pacts were found for this provider".to_string()
1277                )));
1278            };
1279
1280            let links: Result<Vec<(Link, PactVerificationContext)>, PactBrokerError> = pfv.embedded.pacts.iter().map(| p| {
1281          match p.links.get("self") {
1282            Some(l) => Ok((l.clone(), p.into())),
1283            None => Err(
1284              PactBrokerError::LinkError(
1285                format!(
1286                  "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', PATH: '{:?}'",
1287                  hal_client.url,
1288                  p.links,
1289                )
1290              )
1291            )
1292          }
1293        }).collect();
1294
1295            links
1296        }
1297        None => Err(PactBrokerError::NotFound(
1298            "No pacts were found for this provider".to_string(),
1299        )),
1300    }?;
1301
1302    let results: Vec<_> = futures::stream::iter(pact_links)
1303      .map(|(ref pact_link, ref context)| {
1304        match pact_link.href {
1305          Some(_) => Ok((hal_client.clone(), pact_link.clone(), context.clone())),
1306          None => Err(
1307            PactBrokerError::LinkError(
1308              format!(
1309                "Expected a HAL+JSON response from the pact broker, but got a link with no HREF. URL: '{}', LINK: '{:?}'",
1310                hal_client.url,
1311                pact_link
1312              )
1313            )
1314          )
1315        }
1316      })
1317      .and_then(|(hal_client, pact_link, context)| async {
1318        let pact_json = hal_client.fetch_url(
1319          &pact_link.clone(),
1320          &template_values.clone()
1321        ).await?;
1322        Ok((pact_link, pact_json, context))
1323      })
1324      .map(|result| {
1325        match result {
1326          Ok((pact_link, pact_json, context)) => {
1327            let href = pact_link.href.unwrap_or_default();
1328            let links = links_from_json(&pact_json);
1329            load_pact_from_json(href.as_str(), &pact_json)
1330              .map(|pact| (pact, Some(context), links))
1331              .map_err(|err| PactBrokerError::ContentError(format!("{}", err)))
1332          },
1333          Err(err) => Err(err)
1334        }
1335      })
1336      .into_stream()
1337      .collect()
1338      .await;
1339
1340    Ok(results)
1341}
1342
1343/// Fetch the Pact from the given URL, using any required authentication. This will use a GET
1344/// request to the given URL and parse the result into a Pact model. It will also look for any HAL
1345/// links in the response, returning those if found.
1346pub async fn fetch_pact_from_url(
1347    url: &str,
1348    auth: &Option<HttpAuth>,
1349) -> anyhow::Result<(Box<dyn Pact + Send + Sync + RefUnwindSafe>, Vec<Link>)> {
1350    let url = url.to_string();
1351    let auth = auth.clone();
1352    let (url, pact_json) =
1353        tokio::task::spawn_blocking(move || http_utils::fetch_json_from_url(&url, &auth)).await??;
1354    let pact = load_pact_from_json(&url, &pact_json)?;
1355    let links = links_from_json(&pact_json);
1356    Ok((pact, links))
1357}
1358
1359#[skip_serializing_none]
1360#[derive(Serialize, Deserialize, Debug, Clone)]
1361#[serde(rename_all = "camelCase")]
1362/// Structure to represent a HAL link
1363pub struct ConsumerVersionSelector {
1364    /// Application name to filter the results on
1365    pub consumer: Option<String>,
1366    /// Tag
1367    pub tag: Option<String>,
1368    /// Fallback tag if Tag doesn't exist
1369    pub fallback_tag: Option<String>,
1370    /// Only select the latest (if false, this selects all pacts for a tag)
1371    pub latest: Option<bool>,
1372    /// Applications that have been deployed or released
1373    pub deployed_or_released: Option<bool>,
1374    /// Applications that have been deployed
1375    pub deployed: Option<bool>,
1376    /// Applications that have been released
1377    pub released: Option<bool>,
1378    /// Applications in a given environment
1379    pub environment: Option<String>,
1380    /// Applications with the default branch set in the broker
1381    pub main_branch: Option<bool>,
1382    /// Applications with the given branch
1383    pub branch: Option<String>,
1384    /// Applications that match the the provider version branch sent during verification
1385    pub matching_branch: Option<bool>,
1386}
1387
1388#[derive(Serialize, Deserialize, Debug, Clone)]
1389#[serde(rename_all = "camelCase")]
1390struct PactsForVerificationResponse {
1391    #[serde(rename(deserialize = "_embedded"))]
1392    pub embedded: PactsForVerificationBody,
1393}
1394
1395#[derive(Serialize, Deserialize, Debug, Clone)]
1396#[serde(rename_all = "camelCase")]
1397struct PactsForVerificationBody {
1398    pub pacts: Vec<PactForVerification>,
1399}
1400
1401#[derive(Serialize, Deserialize, Debug, Clone)]
1402#[serde(rename_all = "camelCase")]
1403struct PactForVerification {
1404    pub short_description: String,
1405    #[serde(rename(deserialize = "_links"))]
1406    pub links: HashMap<String, Link>,
1407    pub verification_properties: Option<PactVerificationProperties>,
1408}
1409
1410#[skip_serializing_none]
1411#[derive(Serialize, Deserialize, Debug, Clone)]
1412#[serde(rename_all = "camelCase")]
1413/// Request to send to determine the pacts to verify
1414pub struct PactsForVerificationRequest {
1415    /// Provider tags to use for determining pending pacts (if enabled)
1416    #[serde(skip_serializing_if = "Vec::is_empty")]
1417    pub provider_version_tags: Vec<String>,
1418    /// Enable pending pacts feature
1419    pub include_pending_status: bool,
1420    /// Find WIP pacts after given date
1421    pub include_wip_pacts_since: Option<String>,
1422    /// Detailed pact selection criteria , see https://docs.pact.io/pact_broker/advanced_topics/consumer_version_selectors/
1423    pub consumer_version_selectors: Vec<ConsumerVersionSelector>,
1424    /// Current provider version branch if used (instead of tags)
1425    pub provider_version_branch: Option<String>,
1426}
1427
1428#[skip_serializing_none]
1429#[derive(Serialize, Deserialize, Debug, Clone)]
1430#[serde(rename_all = "camelCase")]
1431/// Provides the context on why a Pact was included
1432pub struct PactVerificationContext {
1433    /// Description
1434    pub short_description: String,
1435    /// Properties
1436    pub verification_properties: PactVerificationProperties,
1437}
1438
1439impl From<&PactForVerification> for PactVerificationContext {
1440    fn from(value: &PactForVerification) -> Self {
1441        PactVerificationContext {
1442            short_description: value.short_description.clone(),
1443            verification_properties: value.verification_properties.clone().unwrap_or_default(),
1444        }
1445    }
1446}
1447
1448#[skip_serializing_none]
1449#[derive(Serialize, Deserialize, Debug, Clone, Default)]
1450#[serde(rename_all = "camelCase")]
1451/// Properties associated with the verification context
1452pub struct PactVerificationProperties {
1453    #[serde(default)]
1454    /// If the Pact is pending
1455    pub pending: bool,
1456    /// Notices provided by the Pact Broker
1457    pub notices: Vec<HashMap<String, String>>,
1458}
1459
1460#[cfg(test)]
1461mod hal_client_custom_headers_tests {
1462    use super::*;
1463    use crate::cli::pact_broker::main::types::SslOptions;
1464    use std::collections::HashMap;
1465
1466    fn create_test_custom_headers() -> CustomHeaders {
1467        let mut headers = HashMap::new();
1468        headers.insert("Authorization".to_string(), "Bearer test-token".to_string());
1469        headers.insert("X-API-Key".to_string(), "secret-key".to_string());
1470        CustomHeaders { headers }
1471    }
1472
1473    fn create_cloudflare_custom_headers() -> CustomHeaders {
1474        let mut headers = HashMap::new();
1475        headers.insert(
1476            "CF-Access-Client-Id".to_string(),
1477            "client-id-123".to_string(),
1478        );
1479        headers.insert(
1480            "CF-Access-Client-Secret".to_string(),
1481            "secret-456".to_string(),
1482        );
1483        CustomHeaders { headers }
1484    }
1485
1486    #[test]
1487    fn test_hal_client_with_custom_headers() {
1488        let custom_headers = Some(create_test_custom_headers());
1489        let ssl_options = SslOptions::default();
1490
1491        let client = HALClient::with_url(
1492            "https://test.example.com",
1493            None,
1494            ssl_options,
1495            custom_headers.clone(),
1496        );
1497
1498        assert_eq!(client.url, "https://test.example.com");
1499        assert!(client.custom_headers.is_some());
1500
1501        let headers = client.custom_headers.unwrap();
1502        assert_eq!(headers.headers.len(), 2);
1503        assert_eq!(
1504            headers.headers.get("Authorization"),
1505            Some(&"Bearer test-token".to_string())
1506        );
1507        assert_eq!(
1508            headers.headers.get("X-API-Key"),
1509            Some(&"secret-key".to_string())
1510        );
1511    }
1512
1513    #[test]
1514    fn test_hal_client_with_cloudflare_headers() {
1515        let custom_headers = Some(create_cloudflare_custom_headers());
1516        let ssl_options = SslOptions::default();
1517
1518        let client = HALClient::with_url(
1519            "https://pact-broker.example.com",
1520            None,
1521            ssl_options,
1522            custom_headers.clone(),
1523        );
1524
1525        assert!(client.custom_headers.is_some());
1526
1527        let headers = client.custom_headers.unwrap();
1528        assert_eq!(headers.headers.len(), 2);
1529        assert_eq!(
1530            headers.headers.get("CF-Access-Client-Id"),
1531            Some(&"client-id-123".to_string())
1532        );
1533        assert_eq!(
1534            headers.headers.get("CF-Access-Client-Secret"),
1535            Some(&"secret-456".to_string())
1536        );
1537    }
1538
1539    #[test]
1540    fn test_hal_client_without_custom_headers() {
1541        let ssl_options = SslOptions::default();
1542
1543        let client = HALClient::with_url("https://test.example.com", None, ssl_options, None);
1544
1545        assert!(client.custom_headers.is_none());
1546    }
1547
1548    #[test]
1549    fn test_hal_client_with_auth_and_custom_headers() {
1550        let auth = Some(HttpAuth::Token("bearer-token".to_string()));
1551        let custom_headers = Some(create_test_custom_headers());
1552        let ssl_options = SslOptions::default();
1553
1554        let client = HALClient::with_url(
1555            "https://test.example.com",
1556            auth.clone(),
1557            ssl_options,
1558            custom_headers,
1559        );
1560
1561        assert!(client.auth.is_some());
1562        assert!(client.custom_headers.is_some());
1563
1564        if let Some(HttpAuth::Token(token)) = client.auth {
1565            assert_eq!(token, "bearer-token");
1566        }
1567    }
1568
1569    #[test]
1570    fn test_apply_custom_headers_with_mock_request() {
1571        use reqwest::Client;
1572        use reqwest_middleware::ClientBuilder;
1573
1574        let custom_headers = Some(create_test_custom_headers());
1575        let ssl_options = SslOptions::default();
1576
1577        let client = HALClient::with_url(
1578            "https://test.example.com",
1579            None,
1580            ssl_options,
1581            custom_headers,
1582        );
1583
1584        // Create a mock request builder to test header application
1585        let reqwest_client = Client::new();
1586        let middleware_client = ClientBuilder::new(reqwest_client).build();
1587        let request_builder = middleware_client.get("https://test.example.com/test");
1588
1589        // Apply custom headers
1590        let modified_builder = client.apply_custom_headers(request_builder);
1591
1592        // Build the request to inspect headers
1593        let request = modified_builder.build().unwrap();
1594
1595        // Check that custom headers were applied
1596        assert!(request.headers().contains_key("authorization"));
1597        assert!(request.headers().contains_key("x-api-key"));
1598
1599        assert_eq!(
1600            request
1601                .headers()
1602                .get("authorization")
1603                .unwrap()
1604                .to_str()
1605                .unwrap(),
1606            "Bearer test-token"
1607        );
1608        assert_eq!(
1609            request
1610                .headers()
1611                .get("x-api-key")
1612                .unwrap()
1613                .to_str()
1614                .unwrap(),
1615            "secret-key"
1616        );
1617    }
1618
1619    #[test]
1620    fn test_apply_custom_headers_without_headers() {
1621        use reqwest::Client;
1622        use reqwest_middleware::ClientBuilder;
1623
1624        let ssl_options = SslOptions::default();
1625
1626        let client = HALClient::with_url("https://test.example.com", None, ssl_options, None);
1627
1628        // Create a mock request builder
1629        let reqwest_client = Client::new();
1630        let middleware_client = ClientBuilder::new(reqwest_client).build();
1631        let request_builder = middleware_client.get("https://test.example.com/test");
1632
1633        // Apply custom headers (should be no-op)
1634        let modified_builder = client.apply_custom_headers(request_builder);
1635
1636        // Build the request to inspect headers
1637        let request = modified_builder.build().unwrap();
1638
1639        // Should not contain our test headers
1640        assert!(!request.headers().contains_key("authorization"));
1641        assert!(!request.headers().contains_key("x-api-key"));
1642    }
1643
1644    #[test]
1645    fn test_custom_headers_struct_creation() {
1646        let mut headers = HashMap::new();
1647        headers.insert("Test-Header".to_string(), "test-value".to_string());
1648
1649        let custom_headers = CustomHeaders { headers };
1650
1651        assert_eq!(custom_headers.headers.len(), 1);
1652        assert_eq!(
1653            custom_headers.headers.get("Test-Header"),
1654            Some(&"test-value".to_string())
1655        );
1656    }
1657
1658    #[test]
1659    fn test_custom_headers_empty() {
1660        let headers = HashMap::new();
1661        let custom_headers = CustomHeaders { headers };
1662
1663        assert_eq!(custom_headers.headers.len(), 0);
1664        assert!(custom_headers.headers.is_empty());
1665    }
1666
1667    #[test]
1668    fn test_custom_headers_case_sensitivity() {
1669        let mut headers = HashMap::new();
1670        headers.insert("content-type".to_string(), "application/json".to_string());
1671        headers.insert("Content-Type".to_string(), "text/plain".to_string());
1672
1673        let custom_headers = CustomHeaders { headers };
1674
1675        // Both should exist as separate entries (case sensitive keys)
1676        assert_eq!(custom_headers.headers.len(), 2);
1677        assert_eq!(
1678            custom_headers.headers.get("content-type"),
1679            Some(&"application/json".to_string())
1680        );
1681        assert_eq!(
1682            custom_headers.headers.get("Content-Type"),
1683            Some(&"text/plain".to_string())
1684        );
1685    }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690    use expectest::expect;
1691    use expectest::prelude::*;
1692
1693    use pact_consumer::prelude::*;
1694
1695    use super::*;
1696
1697    #[test]
1698    fn resolve_path_test() {
1699        let client = HALClient::with_url("not a URL", None, SslOptions::default(), None);
1700        expect!(client.resolve_path("/any")).to(be_err());
1701
1702        let client = HALClient::with_url(
1703            "http://localhost-ip4:1234",
1704            None,
1705            SslOptions::default(),
1706            None,
1707        );
1708        expect!(client.resolve_path(""))
1709            .to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1710        expect!(client.resolve_path("/"))
1711            .to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1712        expect!(client.resolve_path("/any"))
1713            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1714        expect!(client.resolve_path("any"))
1715            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1716        expect!(client.resolve_path("any/sub-path"))
1717            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/any/sub-path").unwrap()));
1718        expect!(client.resolve_path("/base-path"))
1719            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1720        expect!(client.resolve_path("/base-path/"))
1721            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/").unwrap()));
1722        expect!(client.resolve_path("/base-path/sub-path"))
1723            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/sub-path").unwrap()));
1724
1725        let client = HALClient::with_url(
1726            "http://localhost-ip4:1234/base-path",
1727            None,
1728            SslOptions::default(),
1729            None,
1730        );
1731        expect!(client.resolve_path(""))
1732            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1733        expect!(client.resolve_path("/"))
1734            .to(be_ok().value(Url::parse("http://localhost-ip4:1234").unwrap()));
1735        expect!(client.resolve_path("/any"))
1736            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/any").unwrap()));
1737        expect!(client.resolve_path("any"))
1738            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/any").unwrap()));
1739        expect!(client.resolve_path("any/sub-path"))
1740            .to(be_ok()
1741                .value(Url::parse("http://localhost-ip4:1234/base-path/any/sub-path").unwrap()));
1742        expect!(client.resolve_path("/base-path"))
1743            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path").unwrap()));
1744        expect!(client.resolve_path("/base-path/"))
1745            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/").unwrap()));
1746        expect!(client.resolve_path("/base-path/sub-path"))
1747            .to(be_ok().value(Url::parse("http://localhost-ip4:1234/base-path/sub-path").unwrap()));
1748    }
1749
1750    #[test_log::test(tokio::test)]
1751    async fn navigate_first_retrieves_the_root_resource() {
1752        let pact_broker =
1753            PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1754                .interaction("a request to a hal resource", "", |mut i| async move {
1755                    i.request.path("/");
1756                    i.response
1757          .header("Content-Type", "application/hal+json")
1758          .body("{\"_links\":{\"next\":{\"href\":\"/abc\"},\"prev\":{\"href\":\"/def\"}}}");
1759                    i
1760                })
1761                .await
1762                .interaction("a request to next", "", |mut i| async move {
1763                    i.request.path("/abc");
1764                    i.response
1765                        .header("Content-Type", "application/json")
1766                        .json_body(json_pattern!("Yay! You found your way here"));
1767                    i
1768                })
1769                .await
1770                .start_mock_server(None, Some(MockServerConfig::default()));
1771
1772        let client = HALClient::with_url(
1773            pact_broker.url().as_str(),
1774            None,
1775            SslOptions::default(),
1776            None,
1777        );
1778        let result = client.navigate("next", &hashmap! {}).await.unwrap();
1779        expect!(result.path_info).to(be_some().value(serde_json::Value::String(
1780            "Yay! You found your way here".to_string(),
1781        )));
1782    }
1783
1784    #[test_log::test(tokio::test)]
1785    async fn navigate_will_not_retrieve_the_root_resource_if_a_path_is_already_set() {
1786        let pact_broker = PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1787            .interaction("a request to next", "", |mut i| async move {
1788                i.request.path("/abc");
1789                i.response
1790                    .header("Content-Type", "application/json")
1791                    .json_body(json_pattern!("Yay! You found your way here"));
1792                i
1793            })
1794            .await
1795            .start_mock_server(None, Some(MockServerConfig::default()));
1796
1797        let mut client = HALClient::with_url(
1798            pact_broker.url().as_str(),
1799            None,
1800            SslOptions::default(),
1801            None,
1802        );
1803        client.path_info = Some(json!({
1804          "_links": {
1805            "next": { "href": "/abc" },
1806            "prev": { "href": "/def" }
1807          }
1808        }));
1809        let result = client.navigate("next", &hashmap! {}).await.unwrap();
1810        expect!(result.path_info).to(be_some().value(serde_json::Value::String(
1811            "Yay! You found your way here".to_string(),
1812        )));
1813    }
1814
1815    #[test_log::test(tokio::test)]
1816    async fn navigate_takes_context_paths_into_account() {
1817        let pact_broker = PactBuilderAsync::new("RustPactVerifier", "PactBrokerStub")
1818      .interaction("a request to a hal resource with base path", "", |mut i| async move {
1819        i.request.path("/base-path");
1820        i.response
1821          .header("Content-Type", "application/hal+json")
1822          .body("{\"_links\":{\"next\":{\"href\":\"/base-path/abc\"},\"prev\":{\"href\":\"/base-path/def\"}}}");
1823        i
1824      })
1825      .await
1826      .interaction("a request to next with a base path", "", |mut i| async move {
1827        i.request.path("/base-path/abc");
1828        i.response
1829          .header("Content-Type", "application/json")
1830          .json_body(json_pattern!("Yay! You found your way here"));
1831        i
1832      })
1833      .await
1834      .start_mock_server(None, Some(MockServerConfig::default()));
1835
1836        let client = HALClient::with_url(
1837            pact_broker.url().join("/base-path").unwrap().as_str(),
1838            None,
1839            SslOptions::default(),
1840            None,
1841        );
1842        let result = client.navigate("next", &hashmap! {}).await.unwrap();
1843        expect!(result.path_info).to(be_some().value(serde_json::Value::String(
1844            "Yay! You found your way here".to_string(),
1845        )));
1846    }
1847}
1848
1849// MARK: RetryMiddleware integration tests
1850
1851#[cfg(test)]
1852mod retry_middleware_tests {
1853    use std::sync::Arc;
1854    use std::sync::atomic::{AtomicUsize, Ordering};
1855
1856    use axum::{Router, body::Body, http::StatusCode, response::Response, routing::get};
1857    use tokio::net::TcpListener;
1858
1859    use super::{HALClient, SslOptions};
1860
1861    async fn spawn_test_server(router: Router) -> String {
1862        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1863        let addr = listener.local_addr().unwrap();
1864        tokio::spawn(async move {
1865            axum::serve(listener, router).await.unwrap();
1866        });
1867        format!("http://{}", addr)
1868    }
1869
1870    fn hal_client(base_url: &str, retries: u8) -> HALClient {
1871        HALClient::with_url(base_url, None, SslOptions::default(), None).with_retry_count(retries)
1872    }
1873
1874    #[tokio::test]
1875    async fn retries_on_429_too_many_requests() {
1876        let request_count = Arc::new(AtomicUsize::new(0));
1877        let count = request_count.clone();
1878
1879        let router = Router::new().route(
1880            "/",
1881            get(move || {
1882                let count = count.clone();
1883                async move {
1884                    let n = count.fetch_add(1, Ordering::SeqCst);
1885                    if n < 2 {
1886                        Response::builder()
1887                            .status(StatusCode::TOO_MANY_REQUESTS)
1888                            .body(Body::from("{\"_links\":{}}"))
1889                            .unwrap()
1890                    } else {
1891                        Response::builder()
1892                            .status(StatusCode::OK)
1893                            .header("content-type", "application/hal+json")
1894                            .body(Body::from("{\"_links\":{}}"))
1895                            .unwrap()
1896                    }
1897                }
1898            }),
1899        );
1900
1901        let base_url = spawn_test_server(router).await;
1902        let client = hal_client(&base_url, 3);
1903        let result = client.fetch("").await;
1904
1905        assert!(result.is_ok(), "expected OK but got: {:?}", result.err());
1906        assert_eq!(request_count.load(Ordering::SeqCst), 3);
1907    }
1908
1909    #[tokio::test]
1910    async fn does_not_retry_404_not_found() {
1911        let request_count = Arc::new(AtomicUsize::new(0));
1912        let count = request_count.clone();
1913
1914        let router = Router::new().route(
1915            "/",
1916            get(move || {
1917                let count = count.clone();
1918                async move {
1919                    count.fetch_add(1, Ordering::SeqCst);
1920                    StatusCode::NOT_FOUND
1921                }
1922            }),
1923        );
1924
1925        let base_url = spawn_test_server(router).await;
1926        let client = hal_client(&base_url, 3);
1927        let _ = client.fetch("").await;
1928
1929        assert_eq!(
1930            request_count.load(Ordering::SeqCst),
1931            1,
1932            "404 should not be retried"
1933        );
1934    }
1935
1936    #[tokio::test]
1937    async fn retries_on_500_internal_server_error() {
1938        let request_count = Arc::new(AtomicUsize::new(0));
1939        let count = request_count.clone();
1940
1941        let router = Router::new().route(
1942            "/",
1943            get(move || {
1944                let count = count.clone();
1945                async move {
1946                    let n = count.fetch_add(1, Ordering::SeqCst);
1947                    if n == 0 {
1948                        StatusCode::INTERNAL_SERVER_ERROR
1949                    } else {
1950                        StatusCode::OK
1951                    }
1952                }
1953            }),
1954        );
1955
1956        let base_url = spawn_test_server(router).await;
1957        let client = hal_client(&base_url, 3);
1958        let _ = client.fetch("").await;
1959
1960        assert_eq!(
1961            request_count.load(Ordering::SeqCst),
1962            2,
1963            "500 should be retried once"
1964        );
1965    }
1966
1967    #[tokio::test]
1968    async fn honours_integer_retry_after_header() {
1969        // Retry-After: 0 exercises the header path without real delay.
1970        let request_count = Arc::new(AtomicUsize::new(0));
1971        let count = request_count.clone();
1972
1973        let router = Router::new().route(
1974            "/",
1975            get(move || {
1976                let count = count.clone();
1977                async move {
1978                    let n = count.fetch_add(1, Ordering::SeqCst);
1979                    if n == 0 {
1980                        Response::builder()
1981                            .status(StatusCode::TOO_MANY_REQUESTS)
1982                            .header("Retry-After", "0")
1983                            .body(Body::from("{\"_links\":{}}"))
1984                            .unwrap()
1985                    } else {
1986                        Response::builder()
1987                            .status(StatusCode::OK)
1988                            .header("content-type", "application/hal+json")
1989                            .body(Body::from("{\"_links\":{}}"))
1990                            .unwrap()
1991                    }
1992                }
1993            }),
1994        );
1995
1996        let base_url = spawn_test_server(router).await;
1997        let client = hal_client(&base_url, 3);
1998        let result = client.fetch("").await;
1999
2000        assert!(result.is_ok());
2001        assert_eq!(request_count.load(Ordering::SeqCst), 2);
2002    }
2003
2004    #[tokio::test]
2005    async fn honours_http_date_retry_after_header() {
2006        // An HTTP-date Retry-After in the past should result in a zero delay,
2007        // confirming that the date form is parsed rather than silently ignored.
2008        let request_count = Arc::new(AtomicUsize::new(0));
2009        let count = request_count.clone();
2010
2011        let router = Router::new().route(
2012            "/",
2013            get(move || {
2014                let count = count.clone();
2015                async move {
2016                    let n = count.fetch_add(1, Ordering::SeqCst);
2017                    if n == 0 {
2018                        Response::builder()
2019                            .status(StatusCode::TOO_MANY_REQUESTS)
2020                            // A date well in the past → delay is immediately 0.
2021                            .header("Retry-After", "Thu, 01 Jan 1970 00:00:00 GMT")
2022                            .body(Body::from("{\"_links\":{}}"))
2023                            .unwrap()
2024                    } else {
2025                        Response::builder()
2026                            .status(StatusCode::OK)
2027                            .header("content-type", "application/hal+json")
2028                            .body(Body::from("{\"_links\":{}}"))
2029                            .unwrap()
2030                    }
2031                }
2032            }),
2033        );
2034
2035        let base_url = spawn_test_server(router).await;
2036        let client = hal_client(&base_url, 3);
2037        let result = client.fetch("").await;
2038
2039        assert!(result.is_ok());
2040        assert_eq!(
2041            request_count.load(Ordering::SeqCst),
2042            2,
2043            "HTTP-date Retry-After should be parsed and retry should happen"
2044        );
2045    }
2046
2047    #[tokio::test]
2048    async fn sends_one_request_when_retries_is_zero() {
2049        let request_count = Arc::new(AtomicUsize::new(0));
2050        let count = request_count.clone();
2051
2052        let router = Router::new().route(
2053            "/",
2054            get(move || {
2055                let count = count.clone();
2056                async move {
2057                    count.fetch_add(1, Ordering::SeqCst);
2058                    Response::builder()
2059                        .status(StatusCode::TOO_MANY_REQUESTS)
2060                        .body(Body::empty())
2061                        .unwrap()
2062                }
2063            }),
2064        );
2065
2066        let base_url = spawn_test_server(router).await;
2067        let client = hal_client(&base_url, 0);
2068        let _ = client.fetch("").await;
2069
2070        assert_eq!(
2071            request_count.load(Ordering::SeqCst),
2072            1,
2073            "retries=0 should send exactly one request"
2074        );
2075    }
2076
2077    #[tokio::test]
2078    async fn returns_last_failure_when_all_retries_exhausted() {
2079        let router = Router::new().route(
2080            "/",
2081            get(|| async {
2082                Response::builder()
2083                    .status(StatusCode::TOO_MANY_REQUESTS)
2084                    .body(Body::empty())
2085                    .unwrap()
2086            }),
2087        );
2088
2089        let base_url = spawn_test_server(router).await;
2090        let client = hal_client(&base_url, 2);
2091        let result = client.fetch("").await;
2092
2093        assert!(
2094            result.is_err(),
2095            "all retries exhausted should return error, got: {:?}",
2096            result
2097        );
2098    }
2099}