use crate::transport::Call;
use snafu::ResultExt;
use url::Url;
use crate::error::{Result, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PathMode {
#[default]
Direct,
UnderBase,
}
fn substitute(segment: &str, path_params: &[(String, String)]) -> String {
let mut rendered = segment.to_owned();
for (name, value) in path_params {
rendered = rendered.replace(&format!("{{{name}}}"), value);
}
rendered
}
fn drop_empty_query(url: &mut Url) {
if url.query() == Some("") {
url.set_query(None);
}
}
pub fn call_url(base: &Url, call: &Call<'_>, mode: PathMode) -> Result<Url> {
let mut url = match mode {
PathMode::Direct => base.join("/").context(error::UrlSnafu)?,
PathMode::UnderBase => {
let mut url = base.clone();
url.set_query(None);
url.set_fragment(None);
url
}
};
{
let mut segments = url
.path_segments_mut()
.map_err(|()| error::NotABaseSnafu.build())?;
match mode {
PathMode::Direct => {
segments.clear();
}
PathMode::UnderBase => {
segments.pop_if_empty();
}
}
for segment in call.path.split('/').filter(|s| !s.is_empty()) {
segments.push(&substitute(segment, &call.path_params));
}
}
{
let mut query = url.query_pairs_mut();
for (name, value) in &call.query {
query.append_pair(name, value);
}
}
drop_empty_query(&mut url);
Ok(url)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn base(raw: &str) -> Url {
Url::parse(raw).unwrap()
}
fn sessions() -> Call<'static> {
Call {
method: "GET",
path: "/v1/sessions",
..Default::default()
}
}
#[test]
fn direct_is_the_default_mode() {
assert_eq!(PathMode::default(), PathMode::Direct);
}
#[test]
fn a_direct_join_drops_a_base_path_prefix() {
let url = call_url(
&base("http://127.0.0.1:8081/base/"),
&sessions(),
PathMode::Direct,
)
.unwrap();
assert_eq!(url.as_str(), "http://127.0.0.1:8081/v1/sessions");
}
#[test]
fn an_under_base_join_preserves_a_gateway_path_prefix() {
let url = call_url(
&base("https://acme.cloud.example/primary/tapes/"),
&sessions(),
PathMode::UnderBase,
)
.unwrap();
assert_eq!(
url.as_str(),
"https://acme.cloud.example/primary/tapes/v1/sessions",
);
}
#[test]
fn an_under_base_join_reads_both_spellings_of_the_same_base() {
for raw in [
"https://acme.cloud.example/primary/tapes/",
"https://acme.cloud.example/primary/tapes",
] {
let url = call_url(&base(raw), &sessions(), PathMode::UnderBase).unwrap();
assert_eq!(
url.as_str(),
"https://acme.cloud.example/primary/tapes/v1/sessions",
"base {raw:?}",
);
}
}
#[test]
fn an_under_base_join_against_a_bare_origin_matches_the_direct_result() {
let bare = base("http://127.0.0.1:8081");
let rooted = base("http://127.0.0.1:8081/");
let expected = "http://127.0.0.1:8081/v1/sessions";
for candidate in [&bare, &rooted] {
assert_eq!(
call_url(candidate, &sessions(), PathMode::UnderBase)
.unwrap()
.as_str(),
expected,
);
assert_eq!(
call_url(candidate, &sessions(), PathMode::Direct)
.unwrap()
.as_str(),
expected,
);
}
}
#[test]
fn a_path_value_is_encoded_as_one_path_segment_in_both_modes() {
let call = Call {
method: "GET",
path: "/v1/sessions/{id}/traces",
path_params: vec![("id".to_owned(), "../admin/seed/demo".to_owned())],
..Default::default()
};
for (mode, prefix) in [
(PathMode::Direct, "http://127.0.0.1:8081/v1/sessions/"),
(
PathMode::UnderBase,
"http://127.0.0.1:8081/primary/tapes/v1/sessions/",
),
] {
let url = call_url(&base("http://127.0.0.1:8081/primary/tapes/"), &call, mode).unwrap();
assert!(url.as_str().starts_with(prefix), "{mode:?} got: {url}");
assert!(!url.path().contains("/admin/"), "{mode:?} got: {url}");
}
}
#[test]
fn an_empty_query_set_leaves_no_bare_question_mark() {
for mode in [PathMode::Direct, PathMode::UnderBase] {
let url = call_url(&base("http://127.0.0.1:8081"), &sessions(), mode).unwrap();
assert_eq!(url.as_str(), "http://127.0.0.1:8081/v1/sessions");
}
}
#[test]
fn query_values_are_form_encoded_under_their_wire_names() {
let call = Call {
method: "GET",
path: "/v1/search/spans",
query: vec![
("query".to_owned(), "gum glow charm".to_owned()),
("since".to_owned(), "2026-07-01T00:00:00Z".to_owned()),
],
..Default::default()
};
for mode in [PathMode::Direct, PathMode::UnderBase] {
let url = call_url(&base("http://127.0.0.1:8081"), &call, mode).unwrap();
let query = url.query().unwrap();
assert!(query.contains("query=gum+glow+charm"), "got: {query}");
assert!(
query.contains("since=2026-07-01T00%3A00%3A00Z"),
"the timestamp must be percent-encoded: {query}",
);
}
}
#[test]
fn a_base_query_or_fragment_is_not_inherited_by_the_request() {
let call = Call {
method: "GET",
path: "/v1/sessions",
query: vec![("limit".to_owned(), "25".to_owned())],
..Default::default()
};
let url = call_url(
&base("https://acme.example/primary/tapes/?trace=1#top"),
&call,
PathMode::UnderBase,
)
.unwrap();
assert_eq!(
url.as_str(),
"https://acme.example/primary/tapes/v1/sessions?limit=25",
);
}
#[test]
fn a_base_that_cannot_carry_a_path_is_refused_rather_than_guessed_at() {
let call = sessions();
let err =
call_url(&base("mailto:ops@example.com"), &call, PathMode::UnderBase).unwrap_err();
assert!(err.to_string().contains("base"), "got: {err}");
}
}