use std::{
borrow::Cow,
collections::BTreeSet,
fmt::{Display, Write},
};
use konst::{iter, slice, string};
use percent_encoding::utf8_percent_encode;
use tracing::warn;
use super::{FeatureFlag, MatrixVersion, SupportedVersions, error::IntoHttpError};
use crate::percent_encode::PATH_PERCENT_ENCODE_SET;
pub trait PathBuilder: Sized {
type Input<'a>;
fn select_path(&self, input: Self::Input<'_>) -> Result<&'static str, IntoHttpError>;
fn make_endpoint_url(
&self,
input: Self::Input<'_>,
base_url: &str,
path_args: &[&dyn Display],
query_string: &str,
) -> Result<String, IntoHttpError> {
let path_with_placeholders = self.select_path(input)?;
let mut res = base_url.strip_suffix('/').unwrap_or(base_url).to_owned();
let mut segments = path_with_placeholders.split('/');
let mut path_args = path_args.iter();
let first_segment = segments.next().expect("split iterator is never empty");
assert!(first_segment.is_empty(), "endpoint paths must start with '/'");
for segment in segments {
if extract_endpoint_path_segment_variable(segment).is_some() {
let arg = path_args
.next()
.expect("number of placeholders must match number of arguments")
.to_string();
let arg = utf8_percent_encode(&arg, PATH_PERCENT_ENCODE_SET);
write!(res, "/{arg}").expect("writing to a String using fmt::Write can't fail");
} else {
res.reserve(segment.len() + 1);
res.push('/');
res.push_str(segment);
}
}
if !query_string.is_empty() {
res.push('?');
res.push_str(query_string);
}
Ok(res)
}
fn all_paths(&self) -> impl Iterator<Item = &'static str>;
#[doc(hidden)]
fn _path_parameters(&self) -> Vec<&'static str>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::exhaustive_structs)]
pub struct VersionHistory {
unstable_paths: &'static [(Option<&'static str>, &'static str)],
stable_paths: &'static [(StablePathSelector, &'static str)],
deprecated: Option<MatrixVersion>,
removed: Option<MatrixVersion>,
}
impl VersionHistory {
pub const fn new(
unstable_paths: &'static [(Option<&'static str>, &'static str)],
stable_paths: &'static [(StablePathSelector, &'static str)],
deprecated: Option<MatrixVersion>,
removed: Option<MatrixVersion>,
) -> Self {
const fn check_path_args_equal(first: &'static str, second: &'static str) {
let mut second_iter = string::split(second, "/").next();
iter::for_each!(first_s in string::split(first, '/') => {
if let Some(first_arg) = extract_endpoint_path_segment_variable(first_s) {
let second_next_arg: Option<&'static str> = loop {
let Some((second_s, second_n_iter)) = second_iter else {
break None;
};
let maybe_second_arg = extract_endpoint_path_segment_variable(second_s);
second_iter = second_n_iter.next();
if let Some(second_arg) = maybe_second_arg {
break Some(second_arg);
}
};
if let Some(second_next_arg) = second_next_arg {
if !string::eq_str(second_next_arg, first_arg) {
panic!("names of endpoint path segment variables do not match");
}
} else {
panic!("counts of endpoint path segment variables do not match");
}
}
});
while let Some((second_s, second_n_iter)) = second_iter {
if extract_endpoint_path_segment_variable(second_s).is_some() {
panic!("counts of endpoint path segment variables do not match");
}
second_iter = second_n_iter.next();
}
}
let ref_path: &str = if let Some((_, s)) = unstable_paths.first() {
s
} else if let Some((_, s)) = stable_paths.first() {
s
} else {
panic!("no endpoint paths supplied")
};
iter::for_each!(unstable_path in slice::iter(unstable_paths) => {
check_path_is_valid(unstable_path.1);
check_path_args_equal(ref_path, unstable_path.1);
});
let mut prev_seen_version: Option<MatrixVersion> = None;
iter::for_each!(version_path in slice::iter(stable_paths) => {
check_path_is_valid(version_path.1);
check_path_args_equal(ref_path, version_path.1);
if let Some(current_version) = version_path.0.version() {
if let Some(prev_seen_version) = prev_seen_version {
let cmp_result = current_version.const_ord(&prev_seen_version);
if cmp_result.is_eq() {
panic!("duplicate matrix version in stable paths")
} else if cmp_result.is_lt() {
panic!("stable paths are not in ascending order")
}
}
prev_seen_version = Some(current_version);
}
});
if let Some(deprecated) = deprecated {
if let Some(prev_seen_version) = prev_seen_version {
let ord_result = prev_seen_version.const_ord(&deprecated);
if !deprecated.is_legacy() && ord_result.is_eq() {
panic!("deprecated version is equal to latest stable path version")
} else if ord_result.is_gt() {
panic!("deprecated version is older than latest stable path version")
}
} else {
panic!("defined deprecated version while no stable path exists")
}
}
if let Some(removed) = removed {
if let Some(deprecated) = deprecated {
let ord_result = deprecated.const_ord(&removed);
if ord_result.is_eq() {
panic!("removed version is equal to deprecated version")
} else if ord_result.is_gt() {
panic!("removed version is older than deprecated version")
}
} else {
panic!("defined removed version while no deprecated version exists")
}
}
Self { unstable_paths, stable_paths, deprecated, removed }
}
pub fn is_supported(&self, considering: &SupportedVersions) -> bool {
match self.versioning_decision_for(&considering.versions) {
VersioningDecision::Removed => false,
VersioningDecision::Version { .. } => true,
VersioningDecision::Feature => self.feature_path(&considering.features).is_some(),
}
}
pub fn versioning_decision_for(
&self,
versions: &BTreeSet<MatrixVersion>,
) -> VersioningDecision {
let is_superset_any =
|version: MatrixVersion| versions.iter().any(|v| v.is_superset_of(version));
let is_superset_all =
|version: MatrixVersion| versions.iter().all(|v| v.is_superset_of(version));
if self.removed.is_some_and(is_superset_all) {
return VersioningDecision::Removed;
}
if self.added_in().is_some_and(is_superset_any) {
let all_deprecated = self.deprecated.is_some_and(is_superset_all);
return VersioningDecision::Version {
any_deprecated: all_deprecated || self.deprecated.is_some_and(is_superset_any),
all_deprecated,
any_removed: self.removed.is_some_and(is_superset_any),
};
}
VersioningDecision::Feature
}
pub fn added_in(&self) -> Option<MatrixVersion> {
self.stable_paths.iter().find_map(|(v, _)| v.version())
}
pub fn deprecated_in(&self) -> Option<MatrixVersion> {
self.deprecated
}
pub fn removed_in(&self) -> Option<MatrixVersion> {
self.removed
}
pub fn unstable(&self) -> Option<&'static str> {
self.unstable_paths.last().map(|(_, path)| *path)
}
pub fn unstable_paths(&self) -> impl Iterator<Item = (Option<&'static str>, &'static str)> {
self.unstable_paths.iter().copied()
}
pub fn stable_paths(&self) -> impl Iterator<Item = (StablePathSelector, &'static str)> {
self.stable_paths.iter().copied()
}
pub fn version_path(&self, versions: &BTreeSet<MatrixVersion>) -> Option<&'static str> {
let version_paths = self
.stable_paths
.iter()
.filter_map(|(selector, path)| selector.version().map(|version| (version, path)));
for (ver, path) in version_paths.rev() {
if versions.iter().any(|v| v.is_superset_of(ver)) {
return Some(path);
}
}
None
}
pub fn feature_path(&self, supported_features: &BTreeSet<FeatureFlag>) -> Option<&'static str> {
let unstable_feature_paths = self
.unstable_paths
.iter()
.filter_map(|(feature, path)| feature.map(|feature| (feature, path)));
let stable_feature_paths = self
.stable_paths
.iter()
.filter_map(|(selector, path)| selector.feature().map(|feature| (feature, path)));
for (feature, path) in unstable_feature_paths.chain(stable_feature_paths).rev() {
if supported_features.iter().any(|supported| supported.as_str() == feature) {
return Some(path);
}
}
None
}
}
impl PathBuilder for VersionHistory {
type Input<'a> = Cow<'a, SupportedVersions>;
fn select_path(
&self,
input: Cow<'_, SupportedVersions>,
) -> Result<&'static str, IntoHttpError> {
match self.versioning_decision_for(&input.versions) {
VersioningDecision::Removed => Err(IntoHttpError::EndpointRemoved(
self.removed.expect("VersioningDecision::Removed implies metadata.removed"),
)),
VersioningDecision::Version { any_deprecated, all_deprecated, any_removed } => {
if any_removed {
if all_deprecated {
warn!(
"endpoint is removed in some (and deprecated in ALL) \
of the following versions: {:?}",
input.versions
);
} else if any_deprecated {
warn!(
"endpoint is removed (and deprecated) in some of the \
following versions: {:?}",
input.versions
);
} else {
unreachable!("any_removed implies *_deprecated");
}
} else if all_deprecated {
warn!(
"endpoint is deprecated in ALL of the following versions: {:?}",
input.versions
);
} else if any_deprecated {
warn!(
"endpoint is deprecated in some of the following versions: {:?}",
input.versions
);
}
Ok(self
.version_path(&input.versions)
.expect("VersioningDecision::Version implies that a version path exists"))
}
VersioningDecision::Feature => self
.feature_path(&input.features)
.or_else(|| self.unstable())
.ok_or(IntoHttpError::NoUnstablePath),
}
}
fn all_paths(&self) -> impl Iterator<Item = &'static str> {
self.unstable_paths().map(|(_, path)| path).chain(self.stable_paths().map(|(_, path)| path))
}
fn _path_parameters(&self) -> Vec<&'static str> {
let path = self.all_paths().next().unwrap();
path.split('/').filter_map(extract_endpoint_path_segment_variable).collect()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[allow(clippy::exhaustive_enums)]
pub enum VersioningDecision {
Feature,
Version {
any_deprecated: bool,
all_deprecated: bool,
any_removed: bool,
},
Removed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::exhaustive_enums)]
pub enum StablePathSelector {
Feature(&'static str),
Version(MatrixVersion),
FeatureAndVersion {
feature: &'static str,
version: MatrixVersion,
},
}
impl StablePathSelector {
pub const fn feature(self) -> Option<&'static str> {
match self {
Self::Feature(feature) | Self::FeatureAndVersion { feature, .. } => Some(feature),
_ => None,
}
}
pub const fn version(self) -> Option<MatrixVersion> {
match self {
Self::Version(version) | Self::FeatureAndVersion { version, .. } => Some(version),
_ => None,
}
}
}
impl From<MatrixVersion> for StablePathSelector {
fn from(value: MatrixVersion) -> Self {
Self::Version(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::exhaustive_structs)]
pub struct SinglePath(&'static str);
impl SinglePath {
pub const fn new(path: &'static str) -> Self {
check_path_is_valid(path);
iter::for_each!(segment in string::split(path, '/') => {
extract_endpoint_path_segment_variable(segment);
});
Self(path)
}
pub fn path(&self) -> &'static str {
self.0
}
}
impl PathBuilder for SinglePath {
type Input<'a> = ();
fn select_path(&self, _input: ()) -> Result<&'static str, IntoHttpError> {
Ok(self.0)
}
fn all_paths(&self) -> impl Iterator<Item = &'static str> {
std::iter::once(self.0)
}
fn _path_parameters(&self) -> Vec<&'static str> {
self.0.split('/').filter_map(extract_endpoint_path_segment_variable).collect()
}
}
const fn check_path_is_valid(path: &'static str) {
iter::for_each!(path_b in slice::iter(path.as_bytes()) => {
match *path_b {
0x21..=0x7E => {},
_ => panic!("path contains invalid (non-ascii or whitespace) characters")
}
});
}
pub const fn extract_endpoint_path_segment_variable(segment: &str) -> Option<&str> {
if string::starts_with(segment, ':') {
panic!("endpoint paths syntax has changed and segment variables must be wrapped by `{{}}`");
}
if let Some(s) = string::strip_prefix(segment, '{') {
let var = string::strip_suffix(s, '}')
.expect("endpoint path segment variable braces mismatch: missing ending `}`");
return Some(var);
}
if string::ends_with(segment, '}') {
panic!("endpoint path segment variable braces mismatch: missing starting `{{`");
}
None
}
#[cfg(test)]
mod tests {
use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet},
};
use assert_matches2::assert_matches;
use super::{PathBuilder, StablePathSelector, VersionHistory};
use crate::api::{
MatrixVersion::{self, V1_0, V1_1, V1_2, V1_3},
SupportedVersions,
error::IntoHttpError,
};
fn stable_only_history(
stable_paths: &'static [(StablePathSelector, &'static str)],
) -> VersionHistory {
VersionHistory { unstable_paths: &[], stable_paths, deprecated: None, removed: None }
}
fn version_only_supported(versions: &[MatrixVersion]) -> SupportedVersions {
SupportedVersions {
versions: versions.iter().copied().collect(),
features: BTreeSet::new(),
}
}
#[test]
fn make_simple_endpoint_url() {
let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s")]);
let url = history
.make_endpoint_url(
Cow::Owned(version_only_supported(&[V1_0])),
"https://example.org",
&[],
"",
)
.unwrap();
assert_eq!(url, "https://example.org/s");
}
#[test]
fn make_endpoint_url_with_path_args() {
let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/{x}")]);
let url = history
.make_endpoint_url(
Cow::Owned(version_only_supported(&[V1_0])),
"https://example.org",
&[&"123"],
"",
)
.unwrap();
assert_eq!(url, "https://example.org/s/123");
}
#[test]
fn make_endpoint_url_with_path_args_with_dash() {
let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/{x}")]);
let url = history
.make_endpoint_url(
Cow::Owned(version_only_supported(&[V1_0])),
"https://example.org",
&[&"my-path"],
"",
)
.unwrap();
assert_eq!(url, "https://example.org/s/my-path");
}
#[test]
fn make_endpoint_url_with_path_args_with_reserved_char() {
let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/{x}")]);
let url = history
.make_endpoint_url(
Cow::Owned(version_only_supported(&[V1_0])),
"https://example.org",
&[&"#path"],
"",
)
.unwrap();
assert_eq!(url, "https://example.org/s/%23path");
}
#[test]
fn make_endpoint_url_with_query() {
let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/")]);
let url = history
.make_endpoint_url(
Cow::Owned(version_only_supported(&[V1_0])),
"https://example.org",
&[],
"foo=bar",
)
.unwrap();
assert_eq!(url, "https://example.org/s/?foo=bar");
}
#[test]
#[should_panic]
fn make_endpoint_url_wrong_num_path_args() {
let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/{x}")]);
_ = history.make_endpoint_url(
Cow::Owned(version_only_supported(&[V1_0])),
"https://example.org",
&[],
"",
);
}
const EMPTY: VersionHistory =
VersionHistory { unstable_paths: &[], stable_paths: &[], deprecated: None, removed: None };
#[test]
fn select_version() {
let version_supported = version_only_supported(&[V1_0, V1_1]);
let superset_supported = version_only_supported(&[V1_1]);
let hist =
VersionHistory { stable_paths: &[(StablePathSelector::Version(V1_0), "/s")], ..EMPTY };
assert_matches!(hist.select_path(Cow::Borrowed(&version_supported)), Ok("/s"));
assert!(hist.is_supported(&version_supported));
assert_matches!(hist.select_path(Cow::Borrowed(&superset_supported)), Ok("/s"));
assert!(hist.is_supported(&superset_supported));
let hist = VersionHistory {
stable_paths: &[(
StablePathSelector::FeatureAndVersion { feature: "org.boo.stable", version: V1_0 },
"/s",
)],
..EMPTY
};
assert_matches!(hist.select_path(Cow::Borrowed(&version_supported)), Ok("/s"));
assert!(hist.is_supported(&version_supported));
assert_matches!(hist.select_path(Cow::Borrowed(&superset_supported)), Ok("/s"));
assert!(hist.is_supported(&superset_supported));
let hist = VersionHistory {
stable_paths: &[
(StablePathSelector::Version(V1_0), "/s_v1"),
(StablePathSelector::Version(V1_1), "/s_v2"),
],
..EMPTY
};
assert_matches!(hist.select_path(Cow::Borrowed(&version_supported)), Ok("/s_v2"));
assert!(hist.is_supported(&version_supported));
let unstable_supported = SupportedVersions {
versions: [V1_0].into(),
features: ["org.boo.unstable".into()].into(),
};
let hist = VersionHistory {
unstable_paths: &[(Some("org.boo.unstable"), "/u")],
stable_paths: &[(StablePathSelector::Version(V1_0), "/s")],
..EMPTY
};
assert_matches!(hist.select_path(Cow::Borrowed(&unstable_supported)), Ok("/s"));
assert!(hist.is_supported(&unstable_supported));
}
#[test]
fn select_stable_feature() {
let supported = SupportedVersions {
versions: [V1_1].into(),
features: ["org.boo.unstable".into(), "org.boo.stable".into()].into(),
};
let hist = VersionHistory {
unstable_paths: &[(Some("org.boo.unstable"), "/u")],
stable_paths: &[(StablePathSelector::Feature("org.boo.stable"), "/s")],
..EMPTY
};
assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/s"));
assert!(hist.is_supported(&supported));
let hist = VersionHistory {
unstable_paths: &[(Some("org.boo.unstable"), "/u")],
stable_paths: &[(
StablePathSelector::FeatureAndVersion { feature: "org.boo.stable", version: V1_3 },
"/s",
)],
..EMPTY
};
assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/s"));
assert!(hist.is_supported(&supported));
}
#[test]
fn select_unstable_feature() {
let supported = SupportedVersions {
versions: [V1_1].into(),
features: ["org.boo.unstable".into()].into(),
};
let hist = VersionHistory {
unstable_paths: &[(Some("org.boo.unstable"), "/u")],
stable_paths: &[(
StablePathSelector::FeatureAndVersion { feature: "org.boo.stable", version: V1_3 },
"/s",
)],
..EMPTY
};
assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/u"));
assert!(hist.is_supported(&supported));
}
#[test]
fn select_unstable_fallback() {
let supported = version_only_supported(&[V1_0]);
let hist = VersionHistory { unstable_paths: &[(None, "/u")], ..EMPTY };
assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/u"));
assert!(!hist.is_supported(&supported));
}
#[test]
fn select_r0() {
let supported = version_only_supported(&[V1_0]);
let hist =
VersionHistory { stable_paths: &[(StablePathSelector::Version(V1_0), "/r")], ..EMPTY };
assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/r"));
assert!(hist.is_supported(&supported));
}
#[test]
fn select_removed_err() {
let supported = version_only_supported(&[V1_3]);
let hist = VersionHistory {
stable_paths: &[
(StablePathSelector::Version(V1_0), "/r"),
(StablePathSelector::Version(V1_1), "/s"),
],
unstable_paths: &[(None, "/u")],
deprecated: Some(V1_2),
removed: Some(V1_3),
};
assert_matches!(
hist.select_path(Cow::Borrowed(&supported)),
Err(IntoHttpError::EndpointRemoved(V1_3))
);
assert!(!hist.is_supported(&supported));
}
#[test]
fn partially_removed_but_stable() {
let supported = version_only_supported(&[V1_2]);
let hist = VersionHistory {
stable_paths: &[
(StablePathSelector::Version(V1_0), "/r"),
(StablePathSelector::Version(V1_1), "/s"),
],
unstable_paths: &[],
deprecated: Some(V1_2),
removed: Some(V1_3),
};
assert_matches!(hist.select_path(Cow::Borrowed(&supported)), Ok("/s"));
assert!(hist.is_supported(&supported));
}
#[test]
fn no_unstable() {
let supported = version_only_supported(&[V1_0]);
let hist =
VersionHistory { stable_paths: &[(StablePathSelector::Version(V1_1), "/s")], ..EMPTY };
assert_matches!(
hist.select_path(Cow::Borrowed(&supported)),
Err(IntoHttpError::NoUnstablePath)
);
assert!(!hist.is_supported(&supported));
}
#[test]
fn version_literal() {
const LIT: MatrixVersion = MatrixVersion::from_lit("1.0");
assert_eq!(LIT, V1_0);
}
#[test]
fn parse_as_str_sanity() {
let version = MatrixVersion::try_from("r0.5.0").unwrap();
assert_eq!(version, V1_0);
assert_eq!(version.as_str(), None);
let version = MatrixVersion::try_from("v1.1").unwrap();
assert_eq!(version, V1_1);
assert_eq!(version.as_str(), Some("v1.1"));
}
#[test]
fn supported_versions_from_parts() {
let empty_features = BTreeMap::new();
let none = &[];
let none_supported = SupportedVersions::from_parts(none, &empty_features);
assert_eq!(none_supported.versions, BTreeSet::new());
assert_eq!(none_supported.features, BTreeSet::new());
let single_known = &["r0.6.0".to_owned()];
let single_known_supported = SupportedVersions::from_parts(single_known, &empty_features);
assert_eq!(single_known_supported.versions, BTreeSet::from([V1_0]));
assert_eq!(single_known_supported.features, BTreeSet::new());
let multiple_known = &["v1.1".to_owned(), "r0.6.0".to_owned(), "r0.6.1".to_owned()];
let multiple_known_supported =
SupportedVersions::from_parts(multiple_known, &empty_features);
assert_eq!(multiple_known_supported.versions, BTreeSet::from([V1_0, V1_1]));
assert_eq!(multiple_known_supported.features, BTreeSet::new());
let single_unknown = &["v0.0".to_owned()];
let single_unknown_supported =
SupportedVersions::from_parts(single_unknown, &empty_features);
assert_eq!(single_unknown_supported.versions, BTreeSet::new());
assert_eq!(single_unknown_supported.features, BTreeSet::new());
let mut features = BTreeMap::new();
features.insert("org.bar.enabled_1".to_owned(), true);
features.insert("org.bar.disabled".to_owned(), false);
features.insert("org.bar.enabled_2".to_owned(), true);
let features_supported = SupportedVersions::from_parts(single_known, &features);
assert_eq!(features_supported.versions, BTreeSet::from([V1_0]));
assert_eq!(
features_supported.features,
["org.bar.enabled_1".into(), "org.bar.enabled_2".into()].into()
);
}
#[test]
fn supported_versions_from_parts_order() {
let empty_features = BTreeMap::new();
let sorted = &[
"r0.0.1".to_owned(),
"r0.5.0".to_owned(),
"r0.6.0".to_owned(),
"r0.6.1".to_owned(),
"v1.1".to_owned(),
"v1.2".to_owned(),
];
let sorted_supported = SupportedVersions::from_parts(sorted, &empty_features);
assert_eq!(sorted_supported.versions, BTreeSet::from([V1_0, V1_1, V1_2]));
let sorted_reverse = &[
"v1.2".to_owned(),
"v1.1".to_owned(),
"r0.6.1".to_owned(),
"r0.6.0".to_owned(),
"r0.5.0".to_owned(),
"r0.0.1".to_owned(),
];
let sorted_reverse_supported =
SupportedVersions::from_parts(sorted_reverse, &empty_features);
assert_eq!(sorted_reverse_supported.versions, BTreeSet::from([V1_0, V1_1, V1_2]));
let random_order = &[
"v1.1".to_owned(),
"r0.6.1".to_owned(),
"r0.5.0".to_owned(),
"r0.6.0".to_owned(),
"r0.0.1".to_owned(),
"v1.2".to_owned(),
];
let random_order_supported = SupportedVersions::from_parts(random_order, &empty_features);
assert_eq!(random_order_supported.versions, BTreeSet::from([V1_0, V1_1, V1_2]));
}
#[test]
#[should_panic]
fn make_endpoint_url_with_path_args_old_syntax() {
let history = stable_only_history(&[(StablePathSelector::Version(V1_0), "/s/:x")]);
let url = history
.make_endpoint_url(
Cow::Owned(version_only_supported(&[V1_0])),
"https://example.org",
&[&"123"],
"",
)
.unwrap();
assert_eq!(url, "https://example.org/s/123");
}
}