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