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