ruma_common/api/
path_builder.rs

1//! The `PathBuilder` trait used to construct the path used to query endpoints and the types that
2//! implement it.
3
4use std::{
5    borrow::Cow,
6    collections::BTreeSet,
7    fmt::{Display, Write},
8};
9
10use percent_encoding::utf8_percent_encode;
11use tracing::warn;
12
13use super::{error::IntoHttpError, FeatureFlag, MatrixVersion, SupportedVersions};
14use crate::percent_encode::PATH_PERCENT_ENCODE_SET;
15
16/// Trait implemented by types providing a method to construct the path used to query an endpoint.
17///
18/// Types implementing this must enforce that all possible paths returned from `select_path()` must
19/// contain the same number of variables.
20pub trait PathBuilder: Sized {
21    /// The input necessary to generate the endpoint URL.
22    type Input<'a>;
23
24    /// Pick the right path according to the given input.
25    ///
26    /// Returns an error if no path could be selected for the given input.
27    fn select_path(&self, input: Self::Input<'_>) -> Result<&'static str, IntoHttpError>;
28
29    /// Generate the endpoint URL for this data, considering the given input.
30    ///
31    /// ## Arguments
32    ///
33    /// * `input` - The input necessary to select the path.
34    /// * `base_url` - The base URL (i.e. the scheme and host) to which the endpoint path will be
35    ///   appended. Since all paths begin with a slash, it is not necessary for the this to have a
36    ///   trailing slash. If it has one however, it will be ignored.
37    /// * `path_args` - The values of the variables in the endpoint's path. The order and number
38    ///   must match the order and number of the variables in the path.
39    /// * `query_string` - The serialized query string to append to the URL.
40    ///
41    /// ## Errors
42    ///
43    /// Returns an error if the `PathBuilder::select_path()` implementation returns an error.
44    ///
45    /// Panics if the number of `path_args` doesn't match the number of variables in the path
46    /// returned by `PathBuilder::select_path()`  must contain the same variables.
47    fn make_endpoint_url(
48        &self,
49        input: Self::Input<'_>,
50        base_url: &str,
51        path_args: &[&dyn Display],
52        query_string: &str,
53    ) -> Result<String, IntoHttpError> {
54        let path_with_placeholders = self.select_path(input)?;
55
56        let mut res = base_url.strip_suffix('/').unwrap_or(base_url).to_owned();
57        let mut segments = path_with_placeholders.split('/');
58        let mut path_args = path_args.iter();
59
60        let first_segment = segments.next().expect("split iterator is never empty");
61        assert!(first_segment.is_empty(), "endpoint paths must start with '/'");
62
63        for segment in segments {
64            if extract_endpoint_path_segment_variable(segment).is_some() {
65                let arg = path_args
66                    .next()
67                    .expect("number of placeholders must match number of arguments")
68                    .to_string();
69                let arg = utf8_percent_encode(&arg, PATH_PERCENT_ENCODE_SET);
70
71                write!(res, "/{arg}").expect("writing to a String using fmt::Write can't fail");
72            } else {
73                res.reserve(segment.len() + 1);
74                res.push('/');
75                res.push_str(segment);
76            }
77        }
78
79        if !query_string.is_empty() {
80            res.push('?');
81            res.push_str(query_string);
82        }
83
84        Ok(res)
85    }
86
87    /// All the possible paths used by the endpoint in canon form.
88    ///
89    /// This is meant to be used to register paths in server routers.
90    fn all_paths(&self) -> impl Iterator<Item = &'static str>;
91
92    /// The list of path parameters in the URL.
93    ///
94    /// Used for `#[test]`s generated by the API macros.
95    #[doc(hidden)]
96    fn _path_parameters(&self) -> Vec<&'static str>;
97}
98
99/// The complete history of this endpoint as far as Ruma knows, together with all variants on
100/// versions stable and unstable.
101///
102/// The amount and positioning of path variables are the same over all path variants.
103#[derive(Clone, Debug, PartialEq, Eq)]
104#[allow(clippy::exhaustive_structs)]
105pub struct VersionHistory {
106    /// A list of unstable paths over this endpoint's history, mapped to optional unstable
107    /// features.
108    ///
109    /// For endpoint querying purposes, the last item will be used as a fallback.
110    unstable_paths: &'static [(Option<&'static str>, &'static str)],
111
112    /// A list of stable paths, mapped to selectors.
113    ///
114    /// Sorted (ascending) by Matrix version.
115    stable_paths: &'static [(StablePathSelector, &'static str)],
116
117    /// The Matrix version that deprecated this endpoint.
118    ///
119    /// Deprecation often precedes one Matrix version before removal.
120    ///
121    /// This will make [`try_into_http_request`](super::OutgoingRequest::try_into_http_request)
122    /// emit a warning, see the corresponding documentation for more information.
123    deprecated: Option<MatrixVersion>,
124
125    /// The Matrix version that removed this endpoint.
126    ///
127    /// This will make [`try_into_http_request`](super::OutgoingRequest::try_into_http_request)
128    /// emit an error, see the corresponding documentation for more information.
129    removed: Option<MatrixVersion>,
130}
131
132impl VersionHistory {
133    /// Constructs an instance of [`VersionHistory`], erroring on compilation if it does not
134    /// pass invariants.
135    ///
136    /// Specifically, this checks the following invariants:
137    ///
138    /// * Path arguments are equal (in order, amount, and argument name) in all path strings
139    /// * In `stable_paths`:
140    ///   * Matrix versions are in ascending order
141    ///   * No matrix version is referenced twice
142    /// * `deprecated`'s version comes after the latest version mentioned in `stable_paths`, except
143    ///   for version 1.0, and only if any stable path is defined
144    /// * `removed` comes after `deprecated`, or after the latest referenced `stable_paths`, like
145    ///   `deprecated`
146    ///
147    /// ## Arguments
148    ///
149    /// * `unstable_paths` - List of unstable paths for the endpoint, mapped to optional unstable
150    ///   features.
151    /// * `stable_paths` - List of stable paths for the endpoint, mapped to selectors.
152    /// * `deprecated` - The Matrix version that deprecated the endpoint, if any.
153    /// * `removed` - The Matrix version that removed the endpoint, if any.
154    pub const fn new(
155        unstable_paths: &'static [(Option<&'static str>, &'static str)],
156        stable_paths: &'static [(StablePathSelector, &'static str)],
157        deprecated: Option<MatrixVersion>,
158        removed: Option<MatrixVersion>,
159    ) -> Self {
160        use konst::{iter, slice, string};
161
162        const fn check_path_is_valid(path: &'static str) {
163            iter::for_each!(path_b in slice::iter(path.as_bytes()) => {
164                match *path_b {
165                    0x21..=0x7E => {},
166                    _ => panic!("path contains invalid (non-ascii or whitespace) characters")
167                }
168            });
169        }
170
171        const fn check_path_args_equal(first: &'static str, second: &'static str) {
172            let mut second_iter = string::split(second, "/").next();
173
174            iter::for_each!(first_s in string::split(first, "/") => {
175                if let Some(first_arg) = string::strip_prefix(first_s, ":") {
176                    let second_next_arg: Option<&'static str> = loop {
177                        let Some((second_s, second_n_iter)) = second_iter else {
178                            break None;
179                        };
180
181                        let maybe_second_arg = string::strip_prefix(second_s, ":");
182
183                        second_iter = second_n_iter.next();
184
185                        if let Some(second_arg) = maybe_second_arg {
186                            break Some(second_arg);
187                        }
188                    };
189
190                    if let Some(second_next_arg) = second_next_arg {
191                        if !string::eq_str(second_next_arg, first_arg) {
192                            panic!("Path Arguments do not match");
193                        }
194                    } else {
195                        panic!("Amount of Path Arguments do not match");
196                    }
197                }
198            });
199
200            // If second iterator still has some values, empty first.
201            while let Some((second_s, second_n_iter)) = second_iter {
202                if string::starts_with(second_s, ":") {
203                    panic!("Amount of Path Arguments do not match");
204                }
205                second_iter = second_n_iter.next();
206            }
207        }
208
209        // The path we're going to use to compare all other paths with
210        let ref_path: &str = if let Some((_, s)) = unstable_paths.first() {
211            s
212        } else if let Some((_, s)) = stable_paths.first() {
213            s
214        } else {
215            panic!("No paths supplied")
216        };
217
218        iter::for_each!(unstable_path in slice::iter(unstable_paths) => {
219            check_path_is_valid(unstable_path.1);
220            check_path_args_equal(ref_path, unstable_path.1);
221        });
222
223        let mut prev_seen_version: Option<MatrixVersion> = None;
224
225        iter::for_each!(version_path in slice::iter(stable_paths) => {
226            check_path_is_valid(version_path.1);
227            check_path_args_equal(ref_path, version_path.1);
228
229            if let Some(current_version) = version_path.0.version() {
230                if let Some(prev_seen_version) = prev_seen_version {
231                    let cmp_result = current_version.const_ord(&prev_seen_version);
232
233                    if cmp_result.is_eq() {
234                        // Found a duplicate, current == previous
235                        panic!("Duplicate matrix version in stable_paths")
236                    } else if cmp_result.is_lt() {
237                        // Found an older version, current < previous
238                        panic!("No ascending order in stable_paths")
239                    }
240                }
241
242                prev_seen_version = Some(current_version);
243            }
244        });
245
246        if let Some(deprecated) = deprecated {
247            if let Some(prev_seen_version) = prev_seen_version {
248                let ord_result = prev_seen_version.const_ord(&deprecated);
249                if !deprecated.is_legacy() && ord_result.is_eq() {
250                    // prev_seen_version == deprecated, except for 1.0.
251                    // It is possible that an endpoint was both made stable and deprecated in the
252                    // legacy versions.
253                    panic!("deprecated version is equal to latest stable path version")
254                } else if ord_result.is_gt() {
255                    // prev_seen_version > deprecated
256                    panic!("deprecated version is older than latest stable path version")
257                }
258            } else {
259                panic!("Defined deprecated version while no stable path exists")
260            }
261        }
262
263        if let Some(removed) = removed {
264            if let Some(deprecated) = deprecated {
265                let ord_result = deprecated.const_ord(&removed);
266                if ord_result.is_eq() {
267                    // deprecated == removed
268                    panic!("removed version is equal to deprecated version")
269                } else if ord_result.is_gt() {
270                    // deprecated > removed
271                    panic!("removed version is older than deprecated version")
272                }
273            } else {
274                panic!("Defined removed version while no deprecated version exists")
275            }
276        }
277
278        Self { unstable_paths, stable_paths, deprecated, removed }
279    }
280
281    /// Whether the homeserver advertises support for a path in this [`VersionHistory`].
282    ///
283    /// Returns `true` if any version or feature in the given [`SupportedVersions`] matches a path
284    /// in this history, unless the endpoint was removed.
285    ///
286    /// Note that this is likely to return false negatives, since some endpoints don't specify a
287    /// stable or unstable feature, and homeservers should not advertise support for a Matrix
288    /// version unless they support all of its features.
289    pub fn is_supported(&self, considering: &SupportedVersions) -> bool {
290        match self.versioning_decision_for(&considering.versions) {
291            VersioningDecision::Removed => false,
292            VersioningDecision::Version { .. } => true,
293            VersioningDecision::Feature => self.feature_path(&considering.features).is_some(),
294        }
295    }
296
297    /// Decide which kind of endpoint to use given the supported versions of a homeserver.
298    ///
299    /// Returns:
300    ///
301    /// - `Removed` if the endpoint is removed in all supported versions.
302    /// - `Version` if the endpoint is stable or deprecated in at least one supported version.
303    /// - `Feature` in all other cases, to look if a feature path is supported, or use the last
304    ///   unstable path as a fallback.
305    ///
306    /// If resulting [`VersioningDecision`] is `Stable`, it will also detail if any version denoted
307    /// deprecation or removal.
308    pub fn versioning_decision_for(
309        &self,
310        versions: &BTreeSet<MatrixVersion>,
311    ) -> VersioningDecision {
312        let is_superset_any =
313            |version: MatrixVersion| versions.iter().any(|v| v.is_superset_of(version));
314        let is_superset_all =
315            |version: MatrixVersion| versions.iter().all(|v| v.is_superset_of(version));
316
317        // Check if all versions removed this endpoint.
318        if self.removed.is_some_and(is_superset_all) {
319            return VersioningDecision::Removed;
320        }
321
322        // Check if *any* version marks this endpoint as stable.
323        if self.added_in().is_some_and(is_superset_any) {
324            let all_deprecated = self.deprecated.is_some_and(is_superset_all);
325
326            return VersioningDecision::Version {
327                any_deprecated: all_deprecated || self.deprecated.is_some_and(is_superset_any),
328                all_deprecated,
329                any_removed: self.removed.is_some_and(is_superset_any),
330            };
331        }
332
333        VersioningDecision::Feature
334    }
335
336    /// Returns the *first* version this endpoint was added in.
337    ///
338    /// Is `None` when this endpoint is unstable/unreleased.
339    pub fn added_in(&self) -> Option<MatrixVersion> {
340        self.stable_paths.iter().find_map(|(v, _)| v.version())
341    }
342
343    /// Returns the Matrix version that deprecated this endpoint, if any.
344    pub fn deprecated_in(&self) -> Option<MatrixVersion> {
345        self.deprecated
346    }
347
348    /// Returns the Matrix version that removed this endpoint, if any.
349    pub fn removed_in(&self) -> Option<MatrixVersion> {
350        self.removed
351    }
352
353    /// Picks the last unstable path, if it exists.
354    pub fn unstable(&self) -> Option<&'static str> {
355        self.unstable_paths.last().map(|(_, path)| *path)
356    }
357
358    /// Returns all unstable path variants in canon form, with optional corresponding feature.
359    pub fn unstable_paths(&self) -> impl Iterator<Item = (Option<&'static str>, &'static str)> {
360        self.unstable_paths.iter().copied()
361    }
362
363    /// Returns all version path variants in canon form, with corresponding selector.
364    pub fn stable_paths(&self) -> impl Iterator<Item = (StablePathSelector, &'static str)> {
365        self.stable_paths.iter().copied()
366    }
367
368    /// The path that should be used to query the endpoint, given a set of supported versions.
369    ///
370    /// Picks the latest path that the versions accept.
371    ///
372    /// Returns an endpoint in the following format;
373    /// - `/_matrix/client/versions`
374    /// - `/_matrix/client/hello/{world}` (`{world}` is a path replacement parameter)
375    ///
376    /// Note: This doesn't handle endpoint removals, check with
377    /// [`versioning_decision_for`](VersionHistory::versioning_decision_for) to see if this endpoint
378    /// is still available.
379    pub fn version_path(&self, versions: &BTreeSet<MatrixVersion>) -> Option<&'static str> {
380        let version_paths = self
381            .stable_paths
382            .iter()
383            .filter_map(|(selector, path)| selector.version().map(|version| (version, path)));
384
385        // Reverse the iterator, to check the "latest" version first.
386        for (ver, path) in version_paths.rev() {
387            // Check if any of the versions are equal or greater than the version the path needs.
388            if versions.iter().any(|v| v.is_superset_of(ver)) {
389                return Some(path);
390            }
391        }
392
393        None
394    }
395
396    /// The path that should be used to query the endpoint, given a list of supported features.
397    pub fn feature_path(&self, supported_features: &BTreeSet<FeatureFlag>) -> Option<&'static str> {
398        let unstable_feature_paths = self
399            .unstable_paths
400            .iter()
401            .filter_map(|(feature, path)| feature.map(|feature| (feature, path)));
402        let stable_feature_paths = self
403            .stable_paths
404            .iter()
405            .filter_map(|(selector, path)| selector.feature().map(|feature| (feature, path)));
406
407        // Reverse the iterator, to check the "latest" features first.
408        for (feature, path) in unstable_feature_paths.chain(stable_feature_paths).rev() {
409            // Return the path of the first supported feature.
410            if supported_features.iter().any(|supported| supported.as_str() == feature) {
411                return Some(path);
412            }
413        }
414
415        None
416    }
417}
418
419impl PathBuilder for VersionHistory {
420    type Input<'a> = Cow<'a, SupportedVersions>;
421
422    /// Pick the right path according to the given input.
423    ///
424    /// This will fail if, for every version in `input`;
425    /// - The endpoint is too old, and has been removed in all versions.
426    ///   ([`EndpointRemoved`](super::error::IntoHttpError::EndpointRemoved))
427    /// - The endpoint is too new, and no unstable path is known for this endpoint.
428    ///   ([`NoUnstablePath`](super::error::IntoHttpError::NoUnstablePath))
429    ///
430    /// Finally, this will emit a warning through [`tracing`] if it detects that any version in
431    /// `input` has deprecated this endpoint.
432    fn select_path(
433        &self,
434        input: Cow<'_, SupportedVersions>,
435    ) -> Result<&'static str, IntoHttpError> {
436        match self.versioning_decision_for(&input.versions) {
437            VersioningDecision::Removed => Err(IntoHttpError::EndpointRemoved(
438                self.removed.expect("VersioningDecision::Removed implies metadata.removed"),
439            )),
440            VersioningDecision::Version { any_deprecated, all_deprecated, any_removed } => {
441                if any_removed {
442                    if all_deprecated {
443                        warn!(
444                            "endpoint is removed in some (and deprecated in ALL) \
445                             of the following versions: {:?}",
446                            input.versions
447                        );
448                    } else if any_deprecated {
449                        warn!(
450                            "endpoint is removed (and deprecated) in some of the \
451                             following versions: {:?}",
452                            input.versions
453                        );
454                    } else {
455                        unreachable!("any_removed implies *_deprecated");
456                    }
457                } else if all_deprecated {
458                    warn!(
459                        "endpoint is deprecated in ALL of the following versions: {:?}",
460                        input.versions
461                    );
462                } else if any_deprecated {
463                    warn!(
464                        "endpoint is deprecated in some of the following versions: {:?}",
465                        input.versions
466                    );
467                }
468
469                Ok(self
470                    .version_path(&input.versions)
471                    .expect("VersioningDecision::Version implies that a version path exists"))
472            }
473            VersioningDecision::Feature => self
474                .feature_path(&input.features)
475                .or_else(|| self.unstable())
476                .ok_or(IntoHttpError::NoUnstablePath),
477        }
478    }
479
480    fn all_paths(&self) -> impl Iterator<Item = &'static str> {
481        self.unstable_paths().map(|(_, path)| path).chain(self.stable_paths().map(|(_, path)| path))
482    }
483
484    fn _path_parameters(&self) -> Vec<&'static str> {
485        let path = self.all_paths().next().unwrap();
486        path.split('/').filter_map(extract_endpoint_path_segment_variable).collect()
487    }
488}
489
490/// A versioning "decision" derived from a set of Matrix versions.
491#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
492#[allow(clippy::exhaustive_enums)]
493pub enum VersioningDecision {
494    /// A feature path should be used, or a fallback.
495    Feature,
496
497    /// A path with a Matrix version should be used.
498    Version {
499        /// If any version denoted deprecation.
500        any_deprecated: bool,
501
502        /// If *all* versions denoted deprecation.
503        all_deprecated: bool,
504
505        /// If any version denoted removal.
506        any_removed: bool,
507    },
508
509    /// This endpoint was removed in all versions, it should not be used.
510    Removed,
511}
512
513/// A selector for a stable path of an endpoint.
514#[derive(Debug, Clone, Copy, PartialEq, Eq)]
515#[allow(clippy::exhaustive_enums)]
516pub enum StablePathSelector {
517    /// The path is available with the given stable feature.
518    Feature(&'static str),
519
520    /// The path was added in the given Matrix version.
521    Version(MatrixVersion),
522
523    /// The path is available via a stable feature and was added in a Matrix version.
524    FeatureAndVersion {
525        /// The stable feature that adds support for the path.
526        feature: &'static str,
527        /// The Matrix version when the path was added.
528        version: MatrixVersion,
529    },
530}
531
532impl StablePathSelector {
533    /// The feature that adds support for the path, if any.
534    pub const fn feature(self) -> Option<&'static str> {
535        match self {
536            Self::Feature(feature) | Self::FeatureAndVersion { feature, .. } => Some(feature),
537            _ => None,
538        }
539    }
540
541    /// The Matrix version when the path was added, if any.
542    pub const fn version(self) -> Option<MatrixVersion> {
543        match self {
544            Self::Version(version) | Self::FeatureAndVersion { version, .. } => Some(version),
545            _ => None,
546        }
547    }
548}
549
550impl From<MatrixVersion> for StablePathSelector {
551    fn from(value: MatrixVersion) -> Self {
552        Self::Version(value)
553    }
554}
555
556/// The endpoint has a single path.
557///
558/// This means that the endpoint has no path history, or the Matrix spec has no way to manage path
559/// history in the API that it is a part of.
560#[derive(Clone, Debug, PartialEq, Eq)]
561#[allow(clippy::exhaustive_structs)]
562pub struct SinglePath(&'static str);
563
564impl SinglePath {
565    /// Construct a new `SinglePath` for the given path.
566    pub const fn new(path: &'static str) -> Self {
567        Self(path)
568    }
569
570    /// The path of the endpoint.
571    pub fn path(&self) -> &'static str {
572        self.0
573    }
574}
575
576impl PathBuilder for SinglePath {
577    type Input<'a> = ();
578
579    fn select_path(&self, _input: ()) -> Result<&'static str, IntoHttpError> {
580        Ok(self.0)
581    }
582
583    fn all_paths(&self) -> impl Iterator<Item = &'static str> {
584        std::iter::once(self.0)
585    }
586
587    fn _path_parameters(&self) -> Vec<&'static str> {
588        self.0.split('/').filter_map(extract_endpoint_path_segment_variable).collect()
589    }
590}
591
592/// Extract the variable of the given endpoint path segment.
593///
594/// The supported syntax for an endpoint path segment variable is `{var}`.
595///
596/// Returns the name of the variable if one was found in the segment, `None` if no variable was
597/// found.
598///
599/// Panics if:
600///
601/// * The segment begins with `{` but doesn't end with `}`.
602/// * The segment ends with `}` but doesn't begin with `{`.
603/// * The segment begins with `:`, which matches the old syntax for endpoint path segment variables.
604pub fn extract_endpoint_path_segment_variable(segment: &str) -> Option<&str> {
605    if segment.starts_with(':') {
606        panic!("endpoint paths syntax has changed and segment variables must be wrapped by `{{}}`");
607    }
608
609    if let Some(var) = segment.strip_prefix('{').map(|s| {
610        s.strip_suffix('}')
611            .expect("endpoint path segment variable braces mismatch: missing ending `}`")
612    }) {
613        return Some(var);
614    }
615
616    if segment.ends_with('}') {
617        panic!("endpoint path segment variable braces mismatch: missing starting `{{`");
618    }
619
620    None
621}
622
623#[cfg(test)]
624mod tests {
625    use std::{
626        borrow::Cow,
627        collections::{BTreeMap, BTreeSet},
628    };
629
630    use assert_matches2::assert_matches;
631
632    use super::{PathBuilder, StablePathSelector, VersionHistory};
633    use crate::api::{
634        error::IntoHttpError,
635        MatrixVersion::{self, V1_0, V1_1, V1_2, V1_3},
636        SupportedVersions,
637    };
638
639    fn stable_only_history(
640        stable_paths: &'static [(StablePathSelector, &'static str)],
641    ) -> VersionHistory {
642        VersionHistory { unstable_paths: &[], stable_paths, deprecated: None, removed: None }
643    }
644
645    fn version_only_supported(versions: &[MatrixVersion]) -> SupportedVersions {
646        SupportedVersions {
647            versions: versions.iter().copied().collect(),
648            features: BTreeSet::new(),
649        }
650    }
651
652    // TODO add test that can hook into tracing and verify the deprecation warning is emitted
653
654    #[test]
655    fn make_simple_endpoint_url() {
656        let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s")]);
657
658        let url = history
659            .make_endpoint_url(
660                Cow::Owned(version_only_supported(&[V1_0])),
661                "https://example.org",
662                &[],
663                "",
664            )
665            .unwrap();
666        assert_eq!(url, "https://example.org/s");
667    }
668
669    #[test]
670    fn make_endpoint_url_with_path_args() {
671        let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/{x}")]);
672        let url = history
673            .make_endpoint_url(
674                Cow::Owned(version_only_supported(&[V1_0])),
675                "https://example.org",
676                &[&"123"],
677                "",
678            )
679            .unwrap();
680        assert_eq!(url, "https://example.org/s/123");
681    }
682
683    #[test]
684    fn make_endpoint_url_with_path_args_with_dash() {
685        let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/{x}")]);
686        let url = history
687            .make_endpoint_url(
688                Cow::Owned(version_only_supported(&[V1_0])),
689                "https://example.org",
690                &[&"my-path"],
691                "",
692            )
693            .unwrap();
694        assert_eq!(url, "https://example.org/s/my-path");
695    }
696
697    #[test]
698    fn make_endpoint_url_with_path_args_with_reserved_char() {
699        let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/{x}")]);
700        let url = history
701            .make_endpoint_url(
702                Cow::Owned(version_only_supported(&[V1_0])),
703                "https://example.org",
704                &[&"#path"],
705                "",
706            )
707            .unwrap();
708        assert_eq!(url, "https://example.org/s/%23path");
709    }
710
711    #[test]
712    fn make_endpoint_url_with_query() {
713        let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/")]);
714        let url = history
715            .make_endpoint_url(
716                Cow::Owned(version_only_supported(&[V1_0])),
717                "https://example.org",
718                &[],
719                "foo=bar",
720            )
721            .unwrap();
722        assert_eq!(url, "https://example.org/s/?foo=bar");
723    }
724
725    #[test]
726    #[should_panic]
727    fn make_endpoint_url_wrong_num_path_args() {
728        let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/{x}")]);
729        _ = history.make_endpoint_url(
730            Cow::Owned(version_only_supported(&[V1_0])),
731            "https://example.org",
732            &[],
733            "",
734        );
735    }
736
737    const EMPTY: VersionHistory =
738        VersionHistory { unstable_paths: &[], stable_paths: &[], deprecated: None, removed: None };
739
740    #[test]
741    fn select_version() {
742        let version_supported = version_only_supported(&[V1_0, V1_1]);
743        let superset_supported = version_only_supported(&[V1_1]);
744
745        // With version only.
746        let hist =
747            VersionHistory { stable_paths: &[(StablePathSelector::Version(V1_0), "/s")], ..EMPTY };
748        assert_matches!(hist.select_path(Cow::Borrowed(&version_supported)), Ok("/s"));
749        assert!(hist.is_supported(&version_supported));
750        assert_matches!(hist.select_path(Cow::Borrowed(&superset_supported)), Ok("/s"));
751        assert!(hist.is_supported(&superset_supported));
752
753        // With feature and version.
754        let hist = VersionHistory {
755            stable_paths: &[(
756                StablePathSelector::FeatureAndVersion { feature: "org.boo.stable", version: V1_0 },
757                "/s",
758            )],
759            ..EMPTY
760        };
761        assert_matches!(hist.select_path(Cow::Borrowed(&version_supported)), Ok("/s"));
762        assert!(hist.is_supported(&version_supported));
763        assert_matches!(hist.select_path(Cow::Borrowed(&superset_supported)), Ok("/s"));
764        assert!(hist.is_supported(&superset_supported));
765
766        // Select latest stable version.
767        let hist = VersionHistory {
768            stable_paths: &[
769                (StablePathSelector::Version(V1_0), "/s_v1"),
770                (StablePathSelector::Version(V1_1), "/s_v2"),
771            ],
772            ..EMPTY
773        };
774        assert_matches!(hist.select_path(Cow::Borrowed(&version_supported)), Ok("/s_v2"));
775        assert!(hist.is_supported(&version_supported));
776
777        // With unstable feature.
778        let unstable_supported = SupportedVersions {
779            versions: [V1_0].into(),
780            features: ["org.boo.unstable".into()].into(),
781        };
782        let hist = VersionHistory {
783            unstable_paths: &[(Some("org.boo.unstable"), "/u")],
784            stable_paths: &[(StablePathSelector::Version(V1_0), "/s")],
785            ..EMPTY
786        };
787        assert_matches!(hist.select_path(Cow::Borrowed(&unstable_supported)), Ok("/s"));
788        assert!(hist.is_supported(&unstable_supported));
789    }
790
791    #[test]
792    fn select_stable_feature() {
793        let supported = SupportedVersions {
794            versions: [V1_1].into(),
795            features: ["org.boo.unstable".into(), "org.boo.stable".into()].into(),
796        };
797
798        // With feature only.
799        let hist = VersionHistory {
800            unstable_paths: &[(Some("org.boo.unstable"), "/u")],
801            stable_paths: &[(StablePathSelector::Feature("org.boo.stable"), "/s")],
802            ..EMPTY
803        };
804        assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/s"));
805        assert!(hist.is_supported(&supported));
806
807        // With feature and version.
808        let hist = VersionHistory {
809            unstable_paths: &[(Some("org.boo.unstable"), "/u")],
810            stable_paths: &[(
811                StablePathSelector::FeatureAndVersion { feature: "org.boo.stable", version: V1_3 },
812                "/s",
813            )],
814            ..EMPTY
815        };
816        assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/s"));
817        assert!(hist.is_supported(&supported));
818    }
819
820    #[test]
821    fn select_unstable_feature() {
822        let supported = SupportedVersions {
823            versions: [V1_1].into(),
824            features: ["org.boo.unstable".into()].into(),
825        };
826
827        let hist = VersionHistory {
828            unstable_paths: &[(Some("org.boo.unstable"), "/u")],
829            stable_paths: &[(
830                StablePathSelector::FeatureAndVersion { feature: "org.boo.stable", version: V1_3 },
831                "/s",
832            )],
833            ..EMPTY
834        };
835        assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/u"));
836        assert!(hist.is_supported(&supported));
837    }
838
839    #[test]
840    fn select_unstable_fallback() {
841        let supported = version_only_supported(&[V1_0]);
842        let hist = VersionHistory { unstable_paths: &[(None, "/u")], ..EMPTY };
843        assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/u"));
844        assert!(!hist.is_supported(&supported));
845    }
846
847    #[test]
848    fn select_r0() {
849        let supported = version_only_supported(&[V1_0]);
850        let hist =
851            VersionHistory { stable_paths: &[(StablePathSelector::Version(V1_0), "/r")], ..EMPTY };
852        assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/r"));
853        assert!(hist.is_supported(&supported));
854    }
855
856    #[test]
857    fn select_removed_err() {
858        let supported = version_only_supported(&[V1_3]);
859        let hist = VersionHistory {
860            stable_paths: &[
861                (StablePathSelector::Version(V1_0), "/r"),
862                (StablePathSelector::Version(V1_1), "/s"),
863            ],
864            unstable_paths: &[(None, "/u")],
865            deprecated: Some(V1_2),
866            removed: Some(V1_3),
867        };
868        assert_matches!(
869            hist.select_path(Cow::Borrowed(&supported)),
870            Err(IntoHttpError::EndpointRemoved(V1_3))
871        );
872        assert!(!hist.is_supported(&supported));
873    }
874
875    #[test]
876    fn partially_removed_but_stable() {
877        let supported = version_only_supported(&[V1_2]);
878        let hist = VersionHistory {
879            stable_paths: &[
880                (StablePathSelector::Version(V1_0), "/r"),
881                (StablePathSelector::Version(V1_1), "/s"),
882            ],
883            unstable_paths: &[],
884            deprecated: Some(V1_2),
885            removed: Some(V1_3),
886        };
887        assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/s"));
888        assert!(hist.is_supported(&supported));
889    }
890
891    #[test]
892    fn no_unstable() {
893        let supported = version_only_supported(&[V1_0]);
894        let hist =
895            VersionHistory { stable_paths: &[(StablePathSelector::Version(V1_1), "/s")], ..EMPTY };
896        assert_matches!(
897            hist.select_path(Cow::Borrowed(&supported)),
898            Err(IntoHttpError::NoUnstablePath)
899        );
900        assert!(!hist.is_supported(&supported));
901    }
902
903    #[test]
904    fn version_literal() {
905        const LIT: MatrixVersion = MatrixVersion::from_lit("1.0");
906
907        assert_eq!(LIT, V1_0);
908    }
909
910    #[test]
911    fn parse_as_str_sanity() {
912        let version = MatrixVersion::try_from("r0.5.0").unwrap();
913        assert_eq!(version, V1_0);
914        assert_eq!(version.as_str(), None);
915
916        let version = MatrixVersion::try_from("v1.1").unwrap();
917        assert_eq!(version, V1_1);
918        assert_eq!(version.as_str(), Some("v1.1"));
919    }
920
921    #[test]
922    fn supported_versions_from_parts() {
923        let empty_features = BTreeMap::new();
924
925        let none = &[];
926        let none_supported = SupportedVersions::from_parts(none, &empty_features);
927        assert_eq!(none_supported.versions, BTreeSet::new());
928        assert_eq!(none_supported.features, BTreeSet::new());
929
930        let single_known = &["r0.6.0".to_owned()];
931        let single_known_supported = SupportedVersions::from_parts(single_known, &empty_features);
932        assert_eq!(single_known_supported.versions, BTreeSet::from([V1_0]));
933        assert_eq!(single_known_supported.features, BTreeSet::new());
934
935        let multiple_known = &["v1.1".to_owned(), "r0.6.0".to_owned(), "r0.6.1".to_owned()];
936        let multiple_known_supported =
937            SupportedVersions::from_parts(multiple_known, &empty_features);
938        assert_eq!(multiple_known_supported.versions, BTreeSet::from([V1_0, V1_1]));
939        assert_eq!(multiple_known_supported.features, BTreeSet::new());
940
941        let single_unknown = &["v0.0".to_owned()];
942        let single_unknown_supported =
943            SupportedVersions::from_parts(single_unknown, &empty_features);
944        assert_eq!(single_unknown_supported.versions, BTreeSet::new());
945        assert_eq!(single_unknown_supported.features, BTreeSet::new());
946
947        let mut features = BTreeMap::new();
948        features.insert("org.bar.enabled_1".to_owned(), true);
949        features.insert("org.bar.disabled".to_owned(), false);
950        features.insert("org.bar.enabled_2".to_owned(), true);
951
952        let features_supported = SupportedVersions::from_parts(single_known, &features);
953        assert_eq!(features_supported.versions, BTreeSet::from([V1_0]));
954        assert_eq!(
955            features_supported.features,
956            ["org.bar.enabled_1".into(), "org.bar.enabled_2".into()].into()
957        );
958    }
959
960    #[test]
961    fn supported_versions_from_parts_order() {
962        let empty_features = BTreeMap::new();
963
964        let sorted = &[
965            "r0.0.1".to_owned(),
966            "r0.5.0".to_owned(),
967            "r0.6.0".to_owned(),
968            "r0.6.1".to_owned(),
969            "v1.1".to_owned(),
970            "v1.2".to_owned(),
971        ];
972        let sorted_supported = SupportedVersions::from_parts(sorted, &empty_features);
973        assert_eq!(sorted_supported.versions, BTreeSet::from([V1_0, V1_1, V1_2]));
974
975        let sorted_reverse = &[
976            "v1.2".to_owned(),
977            "v1.1".to_owned(),
978            "r0.6.1".to_owned(),
979            "r0.6.0".to_owned(),
980            "r0.5.0".to_owned(),
981            "r0.0.1".to_owned(),
982        ];
983        let sorted_reverse_supported =
984            SupportedVersions::from_parts(sorted_reverse, &empty_features);
985        assert_eq!(sorted_reverse_supported.versions, BTreeSet::from([V1_0, V1_1, V1_2]));
986
987        let random_order = &[
988            "v1.1".to_owned(),
989            "r0.6.1".to_owned(),
990            "r0.5.0".to_owned(),
991            "r0.6.0".to_owned(),
992            "r0.0.1".to_owned(),
993            "v1.2".to_owned(),
994        ];
995        let random_order_supported = SupportedVersions::from_parts(random_order, &empty_features);
996        assert_eq!(random_order_supported.versions, BTreeSet::from([V1_0, V1_1, V1_2]));
997    }
998
999    #[test]
1000    #[should_panic]
1001    fn make_endpoint_url_with_path_args_old_syntax() {
1002        let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/:x")]);
1003        let url = history
1004            .make_endpoint_url(
1005                Cow::Owned(version_only_supported(&[V1_0])),
1006                "https://example.org",
1007                &[&"123"],
1008                "",
1009            )
1010            .unwrap();
1011        assert_eq!(url, "https://example.org/s/123");
1012    }
1013}