tapes_client/path.rs
1//! Turning one contract-described call into a URL against a base.
2//!
3//! # Two bases, two meanings
4//!
5//! A standalone client points at a tapes server's root: `http://host:8081`,
6//! and `/v1/sessions` is the whole path. A platform client points at a gateway
7//! that mounts tapes under a prefix — `https://<slug>.<host>/<gateway>/tapes/`
8//! — and `/v1/sessions` has to land *under* that prefix or the request leaves
9//! for the cloud edge root, most likely a 404 and conceivably a wrong-gateway
10//! route, neither of which looks like a URL bug at the call site.
11//!
12//! Those are not the same operation, and a builder that silently picks one is
13//! wrong for the other client. [`PathMode`] makes the caller say which, and
14//! both are pinned by their own test below.
15//!
16//! The percent-encoding rules are shared by both modes and are the reason this
17//! belongs in one place: a path value is substituted into its segment and the
18//! segment is pushed whole through `path_segments_mut`, so a value containing
19//! `../` stays one segment instead of addressing a different route.
20//!
21//! Both surfaces join here. A cassette route and a sealed-contract route are
22//! the same kind of string against the same kind of base, and when the two had
23//! a builder each only one of them had learned about gateway prefixes.
24
25use crate::transport::Call;
26use snafu::ResultExt;
27use url::Url;
28
29use crate::error::{Result, error};
30
31/// How a contract path template is joined onto a base URL.
32///
33/// The default is [`PathMode::Direct`], which is the behaviour every
34/// existing caller of the shared builder already has.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum PathMode {
37 /// The path template is root-absolute: any path prefix on the base is
38 /// dropped, and `/v1/sessions` addresses the server's own root.
39 #[default]
40 Direct,
41 /// The path template is joined *under* the base's path, preserving a
42 /// gateway prefix such as `/<gateway>/tapes/`.
43 UnderBase,
44}
45
46/// Replace the `{name}` placeholders in one path segment.
47///
48/// The result is pushed through `path_segments_mut`, which percent-encodes the
49/// whole segment — so a value containing a slash stays one segment rather than
50/// addressing a different route.
51fn substitute(segment: &str, path_params: &[(String, String)]) -> String {
52 let mut rendered = segment.to_owned();
53 for (name, value) in path_params {
54 rendered = rendered.replace(&format!("{{{name}}}"), value);
55 }
56 rendered
57}
58
59/// Remove the empty query `query_pairs_mut` leaves behind when no pair was
60/// appended.
61///
62/// `url.query_pairs_mut()` sets the query to `Some("")` the moment it is
63/// called, so a request with every parameter unset would go out as
64/// `/v1/sessions?`. Servers ignore it, but it means the same request has two
65/// spellings — which shows up in logs, in cached URLs, and in any test that
66/// compares them.
67fn drop_empty_query(url: &mut Url) {
68 if url.query() == Some("") {
69 url.set_query(None);
70 }
71}
72
73/// Build the URL for one described call against a base, in the given mode.
74pub fn call_url(base: &Url, call: &Call<'_>, mode: PathMode) -> Result<Url> {
75 let mut url = match mode {
76 // `join("/")` resets to the origin root and drops any query or
77 // fragment the base carried.
78 PathMode::Direct => base.join("/").context(error::UrlSnafu)?,
79 PathMode::UnderBase => {
80 let mut url = base.clone();
81 // A base is a mount point, not a request: anything after the path
82 // is not ours to inherit, and leaving a query on it would merge
83 // with the call's own parameters below.
84 url.set_query(None);
85 url.set_fragment(None);
86 url
87 }
88 };
89
90 {
91 let mut segments = url
92 .path_segments_mut()
93 .map_err(|()| error::NotABaseSnafu.build())?;
94 match mode {
95 PathMode::Direct => {
96 segments.clear();
97 }
98 PathMode::UnderBase => {
99 // A base is conventionally written with a trailing slash,
100 // which `url` models as a final empty segment. Left in place
101 // it would produce `/gateway/tapes//v1/sessions`, so the two
102 // spellings of the same base are normalised to one here.
103 segments.pop_if_empty();
104 }
105 }
106 for segment in call.path.split('/').filter(|s| !s.is_empty()) {
107 segments.push(&substitute(segment, &call.path_params));
108 }
109 }
110
111 {
112 let mut query = url.query_pairs_mut();
113 for (name, value) in &call.query {
114 query.append_pair(name, value);
115 }
116 }
117 drop_empty_query(&mut url);
118 Ok(url)
119}
120
121#[cfg(test)]
122#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
123mod tests {
124 use super::*;
125
126 fn base(raw: &str) -> Url {
127 Url::parse(raw).unwrap()
128 }
129
130 fn sessions() -> Call<'static> {
131 Call {
132 method: "GET",
133 path: "/v1/sessions",
134 ..Default::default()
135 }
136 }
137
138 #[test]
139 fn direct_is_the_default_mode() {
140 // The mode every existing caller of the shared builder already has.
141 assert_eq!(PathMode::default(), PathMode::Direct);
142 }
143
144 #[test]
145 fn a_direct_join_drops_a_base_path_prefix() {
146 // A standalone client's `/v1/sessions` addresses the server root, and
147 // this is the behaviour the cassette transport has always had.
148 let url = call_url(
149 &base("http://127.0.0.1:8081/base/"),
150 &sessions(),
151 PathMode::Direct,
152 )
153 .unwrap();
154 assert_eq!(url.as_str(), "http://127.0.0.1:8081/v1/sessions");
155 }
156
157 #[test]
158 fn an_under_base_join_preserves_a_gateway_path_prefix() {
159 // The exact inverse of the root-absolute case, and the reason
160 // `PathMode` exists: a platform client's base mounts tapes under a
161 // gateway prefix, and a builder that reset to root would retarget
162 // every request at the cloud edge.
163 let url = call_url(
164 &base("https://acme.cloud.example/primary/tapes/"),
165 &sessions(),
166 PathMode::UnderBase,
167 )
168 .unwrap();
169 assert_eq!(
170 url.as_str(),
171 "https://acme.cloud.example/primary/tapes/v1/sessions",
172 );
173 }
174
175 #[test]
176 fn an_under_base_join_reads_both_spellings_of_the_same_base() {
177 // A base written without its trailing slash is the same mount point;
178 // `url` models the slash as a final empty segment, which left in
179 // place would double the separator.
180 for raw in [
181 "https://acme.cloud.example/primary/tapes/",
182 "https://acme.cloud.example/primary/tapes",
183 ] {
184 let url = call_url(&base(raw), &sessions(), PathMode::UnderBase).unwrap();
185 assert_eq!(
186 url.as_str(),
187 "https://acme.cloud.example/primary/tapes/v1/sessions",
188 "base {raw:?}",
189 );
190 }
191 }
192
193 #[test]
194 fn an_under_base_join_against_a_bare_origin_matches_the_direct_result() {
195 // With no prefix to preserve the two modes must agree, or a
196 // standalone deployment would depend on which mode its client picked.
197 let bare = base("http://127.0.0.1:8081");
198 let rooted = base("http://127.0.0.1:8081/");
199 let expected = "http://127.0.0.1:8081/v1/sessions";
200 for candidate in [&bare, &rooted] {
201 assert_eq!(
202 call_url(candidate, &sessions(), PathMode::UnderBase)
203 .unwrap()
204 .as_str(),
205 expected,
206 );
207 assert_eq!(
208 call_url(candidate, &sessions(), PathMode::Direct)
209 .unwrap()
210 .as_str(),
211 expected,
212 );
213 }
214 }
215
216 #[test]
217 fn a_path_value_is_encoded_as_one_path_segment_in_both_modes() {
218 // A raw join would let `../` in a value climb out of the route — and
219 // under a prefix, out of the gateway mount entirely.
220 let call = Call {
221 method: "GET",
222 path: "/v1/sessions/{id}/traces",
223 path_params: vec![("id".to_owned(), "../admin/seed/demo".to_owned())],
224 ..Default::default()
225 };
226 for (mode, prefix) in [
227 (PathMode::Direct, "http://127.0.0.1:8081/v1/sessions/"),
228 (
229 PathMode::UnderBase,
230 "http://127.0.0.1:8081/primary/tapes/v1/sessions/",
231 ),
232 ] {
233 let url = call_url(&base("http://127.0.0.1:8081/primary/tapes/"), &call, mode).unwrap();
234 assert!(url.as_str().starts_with(prefix), "{mode:?} got: {url}");
235 assert!(!url.path().contains("/admin/"), "{mode:?} got: {url}");
236 }
237 }
238
239 #[test]
240 fn an_empty_query_set_leaves_no_bare_question_mark() {
241 for mode in [PathMode::Direct, PathMode::UnderBase] {
242 let url = call_url(&base("http://127.0.0.1:8081"), &sessions(), mode).unwrap();
243 assert_eq!(url.as_str(), "http://127.0.0.1:8081/v1/sessions");
244 }
245 }
246
247 #[test]
248 fn query_values_are_form_encoded_under_their_wire_names() {
249 // Form encoding is the spelling both clients converge on: a space is
250 // `+`, and a timestamp's colons are percent-encoded.
251 let call = Call {
252 method: "GET",
253 path: "/v1/search/spans",
254 query: vec![
255 ("query".to_owned(), "gum glow charm".to_owned()),
256 ("since".to_owned(), "2026-07-01T00:00:00Z".to_owned()),
257 ],
258 ..Default::default()
259 };
260 for mode in [PathMode::Direct, PathMode::UnderBase] {
261 let url = call_url(&base("http://127.0.0.1:8081"), &call, mode).unwrap();
262 let query = url.query().unwrap();
263 assert!(query.contains("query=gum+glow+charm"), "got: {query}");
264 assert!(
265 query.contains("since=2026-07-01T00%3A00%3A00Z"),
266 "the timestamp must be percent-encoded: {query}",
267 );
268 }
269 }
270
271 #[test]
272 fn a_base_query_or_fragment_is_not_inherited_by_the_request() {
273 // A base is a mount point, not a request. Inheriting its query would
274 // merge with the call's own parameters and produce a request nobody
275 // wrote.
276 let call = Call {
277 method: "GET",
278 path: "/v1/sessions",
279 query: vec![("limit".to_owned(), "25".to_owned())],
280 ..Default::default()
281 };
282 let url = call_url(
283 &base("https://acme.example/primary/tapes/?trace=1#top"),
284 &call,
285 PathMode::UnderBase,
286 )
287 .unwrap();
288 assert_eq!(
289 url.as_str(),
290 "https://acme.example/primary/tapes/v1/sessions?limit=25",
291 );
292 }
293
294 #[test]
295 fn a_base_that_cannot_carry_a_path_is_refused_rather_than_guessed_at() {
296 let call = sessions();
297 let err =
298 call_url(&base("mailto:ops@example.com"), &call, PathMode::UnderBase).unwrap_err();
299 assert!(err.to_string().contains("base"), "got: {err}");
300 }
301}