1use std::collections::HashMap;
4use std::ops::Not;
5use std::panic::RefUnwindSafe;
6use std::str::from_utf8;
7
8use anyhow::anyhow;
9use futures::stream::*;
10
11use itertools::Itertools;
12use maplit::hashmap;
13
14use pact_models::http_utils;
15use pact_models::http_utils::HttpAuth;
16use pact_models::json_utils::json_to_string;
17
18#[derive(Debug, Clone)]
19pub struct CustomHeaders {
20 pub headers: std::collections::HashMap<String, String>,
21}
22use pact_models::pact::{Pact, load_pact_from_json};
23use regex::{Captures, Regex};
24use reqwest::{Method, Url};
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27use serde_with::skip_serializing_none;
28use tracing::{debug, error, info, trace, warn};
29pub mod branches;
30pub mod can_i_deploy;
31pub mod deployments;
32pub mod environments;
33pub mod pact_publish;
34pub mod pacticipants;
35pub mod pacts;
36pub mod provider_states;
37pub mod subcommands;
38pub mod tags;
39#[cfg(test)]
40pub mod test_utils;
41pub mod types;
42pub mod utils;
43pub mod verification;
44pub mod versions;
45pub mod webhooks;
46use 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#[derive(Debug, Clone, thiserror::Error)]
136pub enum PactBrokerError {
137 #[error("Error with a HAL link - {0}")]
139 LinkError(String),
140 #[error("Error with the content of a HAL resource - {0}")]
142 ContentError(String),
143 #[error("IO Error - {0}")]
144 IoError(String),
146 #[error("Link/Resource was not found - {0}")]
148 NotFound(String),
149 #[error("Invalid URL - {0}")]
151 UrlError(String),
152 #[error("failed validation - {0:?}")]
154 ValidationError(Vec<String>),
155 #[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)]
203pub struct Link {
205 pub name: String,
207 pub href: Option<String>,
209 pub templated: bool,
211 pub title: Option<String>,
213}
214
215impl Link {
216 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 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#[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
300fn 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 if let Ok(secs) = header_value.trim().parse::<u64>() {
325 return Some(std::time::Duration::from_secs(secs));
326 }
327
328 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
339struct RetryMiddleware {
355 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 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 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 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 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 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 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 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 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 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 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 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 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 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 builder = builder.tls_certs_merge(certs);
1009 } else {
1010 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 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
1097pub 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#[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 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 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 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
1343pub 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")]
1362pub struct ConsumerVersionSelector {
1364 pub consumer: Option<String>,
1366 pub tag: Option<String>,
1368 pub fallback_tag: Option<String>,
1370 pub latest: Option<bool>,
1372 pub deployed_or_released: Option<bool>,
1374 pub deployed: Option<bool>,
1376 pub released: Option<bool>,
1378 pub environment: Option<String>,
1380 pub main_branch: Option<bool>,
1382 pub branch: Option<String>,
1384 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")]
1413pub struct PactsForVerificationRequest {
1415 #[serde(skip_serializing_if = "Vec::is_empty")]
1417 pub provider_version_tags: Vec<String>,
1418 pub include_pending_status: bool,
1420 pub include_wip_pacts_since: Option<String>,
1422 pub consumer_version_selectors: Vec<ConsumerVersionSelector>,
1424 pub provider_version_branch: Option<String>,
1426}
1427
1428#[skip_serializing_none]
1429#[derive(Serialize, Deserialize, Debug, Clone)]
1430#[serde(rename_all = "camelCase")]
1431pub struct PactVerificationContext {
1433 pub short_description: String,
1435 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")]
1451pub struct PactVerificationProperties {
1453 #[serde(default)]
1454 pub pending: bool,
1456 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 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 let modified_builder = client.apply_custom_headers(request_builder);
1591
1592 let request = modified_builder.build().unwrap();
1594
1595 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 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 let modified_builder = client.apply_custom_headers(request_builder);
1635
1636 let request = modified_builder.build().unwrap();
1638
1639 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 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#[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 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 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 .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}