Skip to main content

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}