bh_uri_utils/
lib.rs

1// Copyright (C) 2020-2025  The Blockhouse Technology Limited (TBTL).
2//
3// This program is free software: you can redistribute it and/or modify it
4// under the terms of the GNU Affero General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or (at your
6// option) any later version.
7//
8// This program is distributed in the hope that it will be useful, but
9// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
10// or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public
11// License for more details.
12//
13// You should have received a copy of the GNU Affero General Public License
14// along with this program.  If not, see <https://www.gnu.org/licenses/>.
15
16#![deny(missing_docs)]
17#![deny(rustdoc::broken_intra_doc_links)]
18
19//! This crate provides utilities for manipulating the path of URIs, enabling the addition of
20//! prefixes and suffixes to existing URI paths.
21//!
22//! The crate ensures robust handling of errors and supports various URI-like types.  This is
23//! implemented with [`UriPathExtensions`] trait for the following types:
24//!
25//! - [`reqwest::Url`]
26//! - [`iref::UriBuf`]
27//! - `&`[`iref::Uri`]
28//!
29//! # Example
30//!
31//! ```rust
32//! use bh_uri_utils::UriPathExtensions;
33//! use reqwest::Url;
34//!
35//! let url = Url::parse("https://example.com/path").unwrap();
36//! let updated_url = url.add_path_prefix("/prefix").unwrap();
37//! assert_eq!(updated_url.as_str(), "https://example.com/prefix/path");
38//!
39//! let updated_url = updated_url.add_path_suffix("/suffix").unwrap();
40//! assert_eq!(updated_url.as_str(), "https://example.com/prefix/path/suffix");
41//! ```
42//!
43//! # Notes
44//!
45//! The motivation for creating this crate stems from inconsistencies in how various crates handle
46//! URI manipulations, as well as certain well-documented but unintuitive behaviors that can lead
47//! to bugs.
48//!
49//! For example, we encountered the following unexpected situations:
50//!
51//! - `http://localhost:3002/ + /example` resulted in `http://localhost:3002//example`.
52//!
53//! - `http://localhost:3002/protocol/oid4vci/issuer + /.well-known/openid-credential-issuer`
54//!   resulted in `http://localhost:3002/.well-known/openid-credential-issuer`.
55//!
56//! This crate aims to address and standardize such cases to prevent similar issues in the future.
57
58use bherror::{traits::ForeignError as _, Result};
59
60/// Error type returned by [`UriPathExtensions`] methods.
61#[derive(Debug, strum_macros::Display)]
62pub enum Error {
63    /// Error when we fail to construct the URI.
64    #[strum(to_string = "Conversion to UriBuf failed: {0}")]
65    ConversionToUri(String),
66    /// Error when we've received a URI path that doesn't start with a `/`.
67    #[strum(to_string = "Path is not valid: {0}")]
68    InvalidPath(String),
69    /// Error when we fail to parse the received URI path.
70    #[strum(to_string = "Path parsing failed: {0}")]
71    PathParsing(String),
72}
73
74impl bherror::BhError for Error {}
75
76/// A trait for adding prefixes and suffixes to the path component of a URI.
77///
78/// This trait provides methods to modify the path of a URI by appending a prefix or a suffix, with
79/// strict validation rules for the provided paths.
80///
81/// # Errors
82///
83/// The methods return an [`Error`] if the provided prefix or suffix does not meet the validation
84/// rules.
85pub trait UriPathExtensions {
86    /// Resulting type of the URI returned by the methods of this trait.
87    type Output;
88
89    /// Adds prefix to the path of the provided URI.
90    ///
91    /// The function returns an error if the prefix is invalid, empty, ends with the trailing `/`,
92    /// does not start with a `/`, or starts with multiple consecutive `/`s.
93    fn add_path_prefix(self, path: &str) -> Result<Self::Output, Error>;
94
95    /// Adds suffix to the path of the provided URI.
96    ///
97    /// The function returns an error if the suffix is invalid, empty, ends with the trailing `/`,
98    /// does not start with a `/`, or starts with multiple consecutive `/`s.
99    fn add_path_suffix(self, path: &str) -> Result<Self::Output, Error>;
100}
101
102impl UriPathExtensions for reqwest::Url {
103    type Output = Self;
104
105    fn add_path_prefix(self, path: &str) -> Result<Self::Output, Error> {
106        let uri = uri_add_path_prefix(self, path)?;
107        reqwest::Url::parse(uri.as_ref()).foreign_err(|| Error::ConversionToUri(uri.to_string()))
108    }
109
110    fn add_path_suffix(self, path: &str) -> Result<Self::Output, Error> {
111        let uri = uri_add_path_suffix(self, path)?;
112        reqwest::Url::parse(uri.as_ref()).foreign_err(|| Error::ConversionToUri(uri.to_string()))
113    }
114}
115
116impl UriPathExtensions for &iref::Uri {
117    type Output = iref::UriBuf;
118
119    fn add_path_prefix(self, path: &str) -> Result<Self::Output, Error> {
120        uri_add_path_prefix(self, path)
121    }
122
123    fn add_path_suffix(self, path: &str) -> Result<Self::Output, Error> {
124        uri_add_path_suffix(self, path)
125    }
126}
127
128impl UriPathExtensions for iref::UriBuf {
129    type Output = iref::UriBuf;
130
131    fn add_path_prefix(self, path: &str) -> Result<Self::Output, Error> {
132        uri_add_path_prefix(self, path)
133    }
134
135    fn add_path_suffix(self, path: &str) -> Result<Self::Output, Error> {
136        uri_add_path_suffix(self, path)
137    }
138}
139
140fn uri_add_path_suffix<T>(uri: T, path: &str) -> Result<iref::UriBuf, Error>
141where
142    T: TryIntoUriBuf + ToString,
143{
144    let (mut uri, path) = convert_uri_and_path(uri, path)?;
145
146    if let Some(last_segment) = uri.path().last() {
147        if last_segment.as_str() == "" {
148            uri.path_mut().pop();
149        }
150    }
151
152    if uri.path().is_empty() {
153        uri.set_path(&path);
154    } else {
155        append_segments(uri.path_mut(), path.segments());
156    }
157
158    Ok(uri)
159}
160
161fn uri_add_path_prefix<T>(uri: T, path: &str) -> Result<iref::UriBuf, Error>
162where
163    T: TryIntoUriBuf + ToString,
164{
165    let (mut uri, path) = convert_uri_and_path(uri, path)?;
166    let mut path = path.to_owned();
167
168    append_segments(path.as_path_mut(), uri.path().segments());
169
170    uri.set_path(&path);
171
172    Ok(uri)
173}
174
175fn append_segments(mut path: iref::uri::PathMut, segments: iref::uri::Segments) {
176    for segment in segments {
177        path.push(segment);
178    }
179}
180
181fn convert_uri_and_path<T>(uri: T, path: &str) -> Result<(iref::UriBuf, iref::uri::PathBuf), Error>
182where
183    T: TryIntoUriBuf + ToString,
184{
185    if path.is_empty() || !path.starts_with('/') || path.starts_with("//") || path.ends_with('/') {
186        return Err(bherror::Error::root(Error::InvalidPath(path.to_owned())));
187    }
188
189    let uri_as_string = uri.to_string();
190
191    let uri = uri
192        .try_into()
193        .foreign_err(|| Error::ConversionToUri(uri_as_string))?;
194
195    // This is `map_err` because `PathBuf::new` returns non std::Error.
196    let path = iref::uri::PathBuf::new(path.as_bytes().to_vec())
197        .map_err(|_| bherror::Error::root(Error::PathParsing(path.to_owned())))?;
198
199    Ok((uri, path))
200}
201
202trait TryIntoUriBuf {
203    fn try_into(self) -> Result<iref::UriBuf, self::Error>;
204}
205
206impl TryIntoUriBuf for &iref::Uri {
207    fn try_into(self) -> Result<iref::UriBuf, self::Error> {
208        Ok(self.to_owned())
209    }
210}
211
212impl TryIntoUriBuf for reqwest::Url {
213    fn try_into(self) -> Result<iref::UriBuf, self::Error> {
214        let uri = self.to_string().as_bytes().to_vec();
215
216        // This is `map_err` because `UriBuf::new` returns non std::Error.
217        let uri = iref::UriBuf::new(uri)
218            .map_err(|_| bherror::Error::root(Error::ConversionToUri(self.to_string())))?;
219        Ok(uri)
220    }
221}
222
223impl TryIntoUriBuf for iref::UriBuf {
224    fn try_into(self) -> Result<iref::UriBuf, self::Error> {
225        Ok(self)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn to_uri_buf(uri: &str) -> iref::UriBuf {
234        TryInto::<iref::UriBuf>::try_into(uri.to_owned()).expect("Uri is not valid")
235    }
236
237    #[test]
238    fn test_uri_add_path_suffix_with_reqwest_url() {
239        let uri = reqwest::Url::parse(
240            "http://localhost:3002/protocol/oid4vci/issuer/6adf766d-3b29-42d7-8a07-22b32f608a3a",
241        )
242        .unwrap();
243
244        let new_uri = uri_add_path_suffix(uri, "/.well-known/openid-credential-issuer").unwrap();
245
246        assert_eq!(new_uri, "http://localhost:3002/protocol/oid4vci/issuer/6adf766d-3b29-42d7-8a07-22b32f608a3a/.well-known/openid-credential-issuer");
247    }
248
249    #[test]
250    fn test_uri_add_path_prefix_with_reqwest_url() {
251        let uri = reqwest::Url::parse("http://localhost:3002/protocol/a/b/c").unwrap();
252
253        let new_uri = uri_add_path_prefix(uri, "/d/e/f").unwrap();
254
255        assert_eq!(new_uri, "http://localhost:3002/d/e/f/protocol/a/b/c");
256    }
257
258    #[test]
259    fn test_uri_add_path_suffix() {
260        let uri = to_uri_buf(
261            "http://localhost:3002/protocol/oid4vci/issuer/6adf766d-3b29-42d7-8a07-22b32f608a3a",
262        );
263
264        let new_uri = uri_add_path_suffix(uri, "/.well-known/openid-credential-issuer").unwrap();
265
266        assert_eq!(new_uri, "http://localhost:3002/protocol/oid4vci/issuer/6adf766d-3b29-42d7-8a07-22b32f608a3a/.well-known/openid-credential-issuer");
267    }
268
269    #[test]
270    fn test_uri_add_path_suffix_empty_path() {
271        let uri = to_uri_buf("http://example.com");
272
273        let new_uri = uri_add_path_suffix(uri, "/a/b/c").unwrap();
274
275        assert_eq!(new_uri, "http://example.com/a/b/c");
276    }
277
278    #[test]
279    fn test_uri_add_path_suffix_empty_path_trailing_slash() {
280        let uri = to_uri_buf("http://example.com/");
281
282        let new_uri = uri_add_path_suffix(uri, "/a").unwrap();
283
284        assert_eq!(new_uri, "http://example.com/a");
285    }
286
287    #[test]
288    fn test_uri_add_path_suffix_path_trailing_slash() {
289        let uri = to_uri_buf("http://example.com/p/");
290
291        let new_uri = uri_add_path_suffix(uri, "/a").unwrap();
292
293        assert_eq!(new_uri, "http://example.com/p/a");
294    }
295
296    #[test]
297    fn test_uri_add_path_suffix_path_multiple_trailing_slash() {
298        let uri = to_uri_buf("http://example.com//////");
299
300        let new_uri = uri_add_path_suffix(uri, "/a").unwrap();
301
302        assert_eq!(new_uri, "http://example.com//////a");
303    }
304
305    #[test]
306    fn test_uri_add_path_suffix_start_no_slash() {
307        let uri = to_uri_buf("http://example.com/p/");
308
309        let err = uri_add_path_suffix(uri, "a").unwrap_err();
310
311        assert!(matches!(err.error, Error::InvalidPath(_)));
312    }
313
314    #[test]
315    fn test_uri_add_path_suffix_start_multiple_slash() {
316        let uri = to_uri_buf("http://example.com/p/");
317
318        let err = uri_add_path_suffix(uri, "//a").unwrap_err();
319
320        assert!(matches!(err.error, Error::InvalidPath(_)));
321    }
322
323    #[test]
324    fn test_uri_add_path_suffix_empty() {
325        let uri = to_uri_buf("http://example.com/p/");
326
327        let err = uri_add_path_suffix(uri, "").unwrap_err();
328
329        assert!(matches!(err.error, Error::InvalidPath(_)));
330    }
331
332    #[test]
333    fn test_uri_add_path_suffix_end_slash() {
334        let uri = to_uri_buf("http://example.com/p/");
335
336        let err = uri_add_path_suffix(uri, "/a/").unwrap_err();
337
338        assert!(matches!(err.error, Error::InvalidPath(_)));
339    }
340
341    #[test]
342    fn test_uri_add_path_prefix() {
343        let uri = to_uri_buf("http://example.com/path");
344
345        let new_uri = uri_add_path_prefix(uri, "/a/b/c").unwrap();
346
347        assert_eq!(new_uri, "http://example.com/a/b/c/path");
348    }
349
350    #[test]
351    fn test_uri_add_path_prefix_empty_path() {
352        let uri = to_uri_buf("http://example.com");
353
354        let new_uri = uri_add_path_prefix(uri, "/a/b/c").unwrap();
355
356        assert_eq!(new_uri, "http://example.com/a/b/c");
357    }
358
359    #[test]
360    fn test_uri_add_path_prefix_empty_path_trailing_slash() {
361        let uri = to_uri_buf("http://example.com/");
362
363        let new_uri = uri_add_path_prefix(uri, "/a").unwrap();
364
365        assert_eq!(new_uri, "http://example.com/a");
366    }
367
368    #[test]
369    fn test_uri_add_path_prefix_path_multiple_trailing_slash() {
370        let uri = to_uri_buf("http://example.com//////");
371
372        let new_uri = uri_add_path_prefix(uri, "/a").unwrap();
373
374        assert_eq!(new_uri, "http://example.com/a//////");
375    }
376
377    #[test]
378    fn test_uri_add_path_prefix_path_trailing_slash() {
379        let uri = to_uri_buf("http://example.com/p/");
380
381        let new_uri = uri_add_path_prefix(uri, "/a").unwrap();
382
383        assert_eq!(new_uri, "http://example.com/a/p/");
384    }
385
386    #[test]
387    fn test_uri_add_path_prefix_start_no_slash() {
388        let uri = to_uri_buf("http://example.com/p/");
389
390        let err = uri_add_path_prefix(uri, "a").unwrap_err();
391
392        assert!(matches!(err.error, Error::InvalidPath(_)));
393    }
394
395    #[test]
396    fn test_uri_add_path_prefix_start_multiple_slash() {
397        let uri = to_uri_buf("http://example.com/p/");
398
399        let err = uri_add_path_prefix(uri, "//a").unwrap_err();
400
401        assert!(matches!(err.error, Error::InvalidPath(_)));
402    }
403
404    #[test]
405    fn test_uri_add_path_prefix_empty() {
406        let uri = to_uri_buf("http://example.com/p/");
407
408        let err = uri_add_path_prefix(uri, "").unwrap_err();
409
410        assert!(matches!(err.error, Error::InvalidPath(_)));
411    }
412
413    #[test]
414    fn test_uri_add_path_prefix_end_slash() {
415        let uri = to_uri_buf("http://example.com/p/");
416
417        let err = uri_add_path_prefix(uri, "/a/").unwrap_err();
418
419        assert!(matches!(err.error, Error::InvalidPath(_)));
420    }
421
422    #[test]
423    fn test_uri_add_path_prefix_traits_impl() {
424        let uri = "http://example.com/p/";
425        let expected_uri = "http://example.com/a/p/".to_owned();
426        let path = "/a";
427
428        let ref_uri = iref::Uri::new(uri).unwrap();
429        let reqwest_url = reqwest::Url::parse(uri).unwrap();
430        let uri_buf = TryInto::<iref::UriBuf>::try_into(uri.to_owned()).unwrap();
431
432        assert_eq!(ref_uri.add_path_prefix(path).unwrap(), expected_uri);
433        assert_eq!(
434            reqwest_url.add_path_prefix(path).unwrap().to_string(),
435            expected_uri
436        );
437        assert_eq!(uri_buf.add_path_prefix(path).unwrap(), expected_uri);
438    }
439
440    #[test]
441    fn test_uri_add_path_suffix_traits_impl() {
442        let uri = "http://example.com/p/";
443        let expected_uri = "http://example.com/p/a".to_owned();
444        let path = "/a";
445
446        let ref_uri = iref::Uri::new(uri).unwrap();
447        let reqwest_url = reqwest::Url::parse(uri).unwrap();
448        let uri_buf = TryInto::<iref::UriBuf>::try_into(uri.to_owned()).unwrap();
449
450        assert_eq!(ref_uri.add_path_suffix(path).unwrap(), expected_uri);
451        assert_eq!(
452            reqwest_url.add_path_suffix(path).unwrap().to_string(),
453            expected_uri
454        );
455        assert_eq!(uri_buf.add_path_suffix(path).unwrap(), expected_uri);
456    }
457}