Skip to content

Commit e3d90c1

Browse files
committed
fix(verifier): support pact-broker on subpath
1 parent 691a029 commit e3d90c1

1 file changed

Lines changed: 125 additions & 55 deletions

File tree

rust/pact_verifier/src/pact_broker.rs

Lines changed: 125 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,68 @@ fn find_entry(map: &serde_json::Map<String, Value>, key: &str) -> Option<(String
6969
}
7070
}
7171

72+
/// Returns `true` when `path` equals `context_path` or starts with `context_path/`.
73+
///
74+
/// This prevents a broker context path such as `/pact` from falsely matching an
75+
/// unrelated path like `/pactfoo/bar` via a naive `starts_with` check.
76+
fn has_context_prefix(path: &str, context_path: &str) -> bool {
77+
path == context_path || path.starts_with(&format!("{}/", context_path))
78+
}
79+
80+
/// Extracts the path component from `url_string` (stripping scheme/host if it is an
81+
/// absolute URL) and prepends `broker_url`'s context path when it is missing.
82+
///
83+
/// Accepts an already-parsed `broker_url` so callers that have parsed it for
84+
/// another purpose need not parse it a second time.
85+
fn normalize_path_inner(url_string: &str, broker_url: &Url) -> String {
86+
let mut path = if let Ok(parsed_url) = url_string.parse::<Url>() {
87+
// Absolute URL: extract only the path, so we keep the broker's host.
88+
parsed_url.path().to_string()
89+
} else {
90+
// Already a path (relative or absolute).
91+
url_string.to_string()
92+
};
93+
94+
let context_path = broker_url.path();
95+
if !context_path.is_empty() && context_path != "/" && path.starts_with('/') {
96+
if !has_context_prefix(&path, context_path) {
97+
debug!("Prepending context path '{}' to path '{}'", context_path, path);
98+
path = format!("{}{}", context_path, path);
99+
}
100+
}
101+
path
102+
}
103+
104+
/// Resolves a normalised `path` against an already-parsed `broker_url`,
105+
/// respecting any context path embedded in the broker URL.
106+
///
107+
/// Accepts ownership of `broker_url` so callers that have already parsed it
108+
/// can pass it through without a second `parse::<Url>()` call.
109+
fn resolve_path_inner(path: &str, broker_url: Url) -> Result<Url, PactBrokerError> {
110+
let context_path = broker_url.path().to_string();
111+
let url = if path.is_empty() {
112+
broker_url
113+
} else if !context_path.is_empty() && context_path != "/" {
114+
if has_context_prefix(path, &context_path) {
115+
let mut base_url = broker_url;
116+
base_url.set_path("/");
117+
base_url.join(path)?
118+
} else if path.starts_with('/') {
119+
let mut base_url = broker_url;
120+
base_url.set_path(path);
121+
base_url
122+
} else {
123+
let mut base_url = broker_url;
124+
let cp = format!("{}/", context_path);
125+
base_url.set_path(&cp);
126+
base_url.join(path)?
127+
}
128+
} else {
129+
broker_url.join(path)?
130+
};
131+
Ok(url)
132+
}
133+
72134
/// Errors that can occur with a Pact Broker
73135
#[derive(Debug, Clone, thiserror::Error)]
74136
pub enum PactBrokerError {
@@ -340,16 +402,24 @@ impl HALClient {
340402
))
341403
}?;
342404

343-
let base_url = self.url.parse::<Url>()?;
344-
let joined_url = base_url.join(&link_url)?;
345-
self.fetch(joined_url.path().into()).await
405+
// Parse broker URL once and use both inner functions to avoid double-parsing.
406+
let broker_url = self.url.parse::<Url>()?;
407+
let path = normalize_path_inner(&link_url, &broker_url);
408+
let url = resolve_path_inner(&path, broker_url)?;
409+
self.fetch_with_url(&path, url).await
346410
}
347411

348412
async fn fetch(&self, path: &str) -> Result<Value, PactBrokerError> {
349413
info!("Fetching path '{}' from pact broker", path);
350414
trace!(%path, broker_url = %self.url, ">> fetch");
351-
352415
let url = self.resolve_path(path)?;
416+
self.fetch_with_url(path, url).await
417+
}
418+
419+
/// Performs the actual GET request using an already-resolved `url`.
420+
async fn fetch_with_url(&self, path: &str, url: Url) -> Result<Value, PactBrokerError> {
421+
info!("Fetching path '{}' from pact broker", path);
422+
trace!(%path, broker_url = %self.url, ">> fetch_with_url");
353423
debug!("Final broker URL: {}", url);
354424

355425
let request_builder = match self.auth {
@@ -364,41 +434,18 @@ impl HALClient {
364434
let response = with_retries(self.retries, request_builder).await
365435
.map_err(|err| {
366436
PactBrokerError::IoError(format!("Failed to access pact broker path '{}' - {}. URL: '{}'",
367-
&path,
437+
path,
368438
err,
369439
&self.url,
370440
))
371441
})?;
372442

373-
self.parse_broker_response(path.to_string(), response)
374-
.await
443+
self.parse_broker_response(path.to_string(), response).await
375444
}
376445

377446
fn resolve_path(&self, path: &str) -> Result<Url, PactBrokerError> {
378447
let broker_url = self.url.parse::<Url>()?;
379-
let context_path = broker_url.path();
380-
let url = if path.is_empty() {
381-
broker_url
382-
} else if !context_path.is_empty() && context_path != "/" {
383-
if path.starts_with(context_path) {
384-
let mut base_url = broker_url.clone();
385-
base_url.set_path("/");
386-
base_url.join(path)?
387-
} else if path.starts_with("/") {
388-
let mut base_url = broker_url.clone();
389-
base_url.set_path(path);
390-
base_url
391-
} else {
392-
let mut base_url = broker_url.clone();
393-
let mut cp = context_path.to_string();
394-
cp.push('/');
395-
base_url.set_path(cp.as_str());
396-
base_url.join(path)?
397-
}
398-
} else {
399-
broker_url.join(path)?
400-
};
401-
Ok(url)
448+
resolve_path_inner(path, broker_url)
402449
}
403450

404451
async fn parse_broker_response(
@@ -528,29 +575,10 @@ impl HALClient {
528575
async fn send_document(&self, url: &str, body: &str, method: Method) -> Result<Value, PactBrokerError> {
529576
debug!("Sending JSON to {} using {}: {}", url, method, body);
530577

531-
// Extract path from URL if it's absolute (like fetch_url does), then resolve with context path
532-
let mut path = if let Ok(link_as_url) = url.parse::<Url>() {
533-
// URL is absolute, extract path to use broker's original host and context path
534-
link_as_url.path().to_string()
535-
} else {
536-
// URL is already a path (relative)
537-
url.to_string()
538-
};
539-
540-
// If we have a context path and the path doesn't already include it, prepend it
578+
// Parse broker URL once and use inner functions for both steps.
541579
let broker_url = self.url.parse::<Url>()?;
542-
let context_path = broker_url.path();
543-
if !context_path.is_empty() && context_path != "/" && path.starts_with("/") {
544-
let context_with_slash = format!("{}/", context_path);
545-
let path_matches_context = path == context_path || path.starts_with(&context_with_slash);
546-
if !path_matches_context {
547-
// Path doesn't include context path, prepend it
548-
let full_path = format!("{}{}", context_path, path);
549-
path = full_path;
550-
}
551-
}
552-
553-
let url = self.resolve_path(path.as_str())?;
580+
let path = normalize_path_inner(url, &broker_url);
581+
let url = resolve_path_inner(&path, broker_url)?;
554582

555583
let request_builder = match self.auth {
556584
Some(ref auth) => match auth {
@@ -2695,17 +2723,59 @@ mod tests {
26952723

26962724

26972725
#[test]
2698-
fn resolve_path_handles_absolute_links_correctly() {
2726+
fn resolve_path_with_context_prefixed_path() {
2727+
// Path already contains the context prefix — resolve_path must not double-prepend it.
26992728
let client = HALClientBuilder::builder()
27002729
.with_url("http://127.0.0.1:8080/pact", None)
27012730
.build();
2702-
2731+
27032732
let path = "/pact/pacts/provider/Example%20API/for-verification";
27042733
let resolved = client.resolve_path(path).expect("Should resolve path");
2705-
2734+
27062735
expect!(resolved.path()).to(be_equal_to("/pact/pacts/provider/Example%20API/for-verification"));
27072736
}
27082737

2738+
#[test]
2739+
fn resolve_path_does_not_match_context_prefix_without_boundary() {
2740+
// "/pactfoo/bar" starts with "/pact" lexically but must NOT be treated as
2741+
// already having context path "/pact".
2742+
let client = HALClientBuilder::builder()
2743+
.with_url("http://127.0.0.1:8080/pact", None)
2744+
.build();
2745+
2746+
let path = "/pactfoo/bar";
2747+
let resolved = client.resolve_path(path).expect("Should resolve path");
2748+
2749+
// The path does not start with /pact/, so it is treated as an absolute path
2750+
// without a context prefix and set directly.
2751+
expect!(resolved.path()).to(be_equal_to("/pactfoo/bar"));
2752+
}
2753+
2754+
#[test]
2755+
fn normalize_path_from_url_prepends_context_for_absolute_url_missing_context() {
2756+
// Broker lives at http://host/pact but the link contains an absolute URL
2757+
// from a different host that omits the context path. normalize_path_inner
2758+
// must strip the foreign host and prepend the broker's context path.
2759+
let broker_url = "http://127.0.0.1:8080/pact".parse::<Url>().unwrap();
2760+
let absolute_link = "http://other-broker.example.com/pacts/provider/Example%20API/for-verification";
2761+
2762+
let normalized = normalize_path_inner(absolute_link, &broker_url);
2763+
2764+
expect!(normalized).to(be_equal_to("/pact/pacts/provider/Example%20API/for-verification"));
2765+
}
2766+
2767+
#[test]
2768+
fn normalize_path_from_url_does_not_double_prepend_context_path() {
2769+
// When the absolute URL already carries the correct context path the
2770+
// function must leave it unchanged.
2771+
let broker_url = "http://127.0.0.1:8080/pact".parse::<Url>().unwrap();
2772+
let absolute_link = "http://other-broker.example.com/pact/pacts/provider/Example%20API/latest";
2773+
2774+
let normalized = normalize_path_inner(absolute_link, &broker_url);
2775+
2776+
expect!(normalized).to(be_equal_to("/pact/pacts/provider/Example%20API/latest"));
2777+
}
2778+
27092779
#[test_log::test(tokio::test)]
27102780
async fn subpath_broker_templates_are_substituted() {
27112781
let pact_broker = PactBuilderAsync::new("RustPactVerifier", "TemplateTest")

0 commit comments

Comments
 (0)