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