Skip to main content

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