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