junction_api/xds/
http.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    fmt::Debug,
4    str::FromStr,
5};
6
7use crate::{
8    error::{Error, ErrorContext},
9    http::{
10        BackendRef, HeaderMatch, Method, PathMatch, QueryParamMatch, Route, RouteMatch, RouteRetry,
11        RouteRule, RouteTimeouts,
12    },
13    shared::{Duration, Regex},
14    Name,
15};
16use xds_api::pb::{
17    envoy::{
18        config::{
19            core::v3 as xds_core,
20            route::v3::{self as xds_route, query_parameter_matcher::QueryParameterMatchSpecifier},
21        },
22        r#type::matcher::v3::{string_matcher::MatchPattern, StringMatcher},
23    },
24    google::{
25        self,
26        protobuf::{self, UInt32Value},
27    },
28};
29
30use crate::xds::shared::{parse_xds_regex, regex_matcher};
31
32impl TryInto<Route> for &xds_route::RouteConfiguration {
33    type Error = Error;
34
35    fn try_into(self) -> Result<Route, Self::Error> {
36        Route::from_xds(self)
37    }
38}
39
40impl From<&Route> for xds_route::RouteConfiguration {
41    fn from(route: &Route) -> Self {
42        route.to_xds()
43    }
44}
45
46impl Route {
47    pub fn from_xds(xds: &xds_route::RouteConfiguration) -> Result<Self, Error> {
48        let id = Name::from_str(&xds.name).with_field("name")?;
49        let tags = tags_from_xds(&xds.metadata)?;
50
51        let [vhost] = &xds.virtual_hosts[..] else {
52            return Err(Error::new_static(
53                "RouteConfiguration must have exactly one VirtualHost",
54            ));
55        };
56
57        let mut hostnames = Vec::new();
58        let mut ports = Vec::new();
59        for (i, domain) in vhost.domains.iter().enumerate() {
60            let (name, port) = crate::parse_port(domain).with_field_index("domains", i)?;
61
62            let name = crate::http::HostnameMatch::from_str(name)
63                .with_field_index("domains", i)
64                .with_field_index("virtual_hosts", 0)?;
65
66            hostnames.push(name);
67            if let Some(port) = port {
68                ports.push(port);
69            }
70        }
71        hostnames.sort();
72        hostnames.dedup();
73        ports.sort();
74        ports.dedup();
75
76        let mut rules = vec![];
77        let actions_and_matches = vhost.routes.iter().enumerate().map(|(route_idx, route)| {
78            let key = (route.name.as_str(), route.action.as_ref());
79            (key, (route_idx, route))
80        });
81
82        for ((name, action), matches) in group_by(actions_and_matches) {
83            // safety: group_by shouldn't be able to emit an empty group
84            let action_idx = matches
85                .first()
86                .map(|(idx, _)| *idx)
87                .expect("missing route index");
88
89            let Some(action) = &action else {
90                return Err(Error::new_static("route has no route action"))
91                    .with_field_index("routes", action_idx)
92                    .with_field_index("virtual_hosts", 0);
93            };
94
95            rules.push(
96                RouteRule::from_xds_grouped(name, action, &matches)
97                    .with_field_index("virtual_hosts", 0)?,
98            );
99        }
100
101        Ok(Route {
102            id,
103            hostnames,
104            ports,
105            tags,
106            rules,
107        })
108    }
109
110    pub fn to_xds(&self) -> xds_route::RouteConfiguration {
111        let routes = self.rules.iter().flat_map(|rule| rule.to_xds()).collect();
112
113        let mut domains = Vec::with_capacity(self.hostnames.len() * self.ports.len().min(1));
114        for hostname in &self.hostnames {
115            if self.ports.is_empty() {
116                domains.push(hostname.to_string());
117            } else {
118                for port in &self.ports {
119                    domains.push(format!("{hostname}:{port}"));
120                }
121            }
122        }
123
124        let virtual_hosts = vec![xds_route::VirtualHost {
125            domains,
126            routes,
127            ..Default::default()
128        }];
129
130        let name = self.id.to_string();
131        let metadata = tags_to_xds(&self.tags);
132
133        xds_route::RouteConfiguration {
134            name,
135            metadata,
136            virtual_hosts,
137            ..Default::default()
138        }
139    }
140}
141
142const JUNCTION_ROUTE_TAGS: &str = "io.junctionlabs.route.tags";
143
144fn tags_to_xds(tags: &BTreeMap<String, String>) -> Option<xds_core::Metadata> {
145    if tags.is_empty() {
146        return None;
147    }
148
149    let fields: HashMap<_, _> = tags
150        .iter()
151        .map(|(k, v)| {
152            let v = protobuf::Value {
153                kind: Some(protobuf::value::Kind::StringValue(v.clone())),
154            };
155            (k.clone(), v)
156        })
157        .collect();
158
159    let mut metadata = xds_core::Metadata::default();
160    metadata
161        .filter_metadata
162        .insert(JUNCTION_ROUTE_TAGS.to_string(), protobuf::Struct { fields });
163
164    Some(metadata)
165}
166
167fn tags_from_xds(metadata: &Option<xds_core::Metadata>) -> Result<BTreeMap<String, String>, Error> {
168    let Some(metadata) = metadata else {
169        return Ok(Default::default());
170    };
171
172    let Some(route_tags) = metadata.filter_metadata.get(JUNCTION_ROUTE_TAGS) else {
173        return Ok(Default::default());
174    };
175
176    let mut tags = BTreeMap::new();
177    for (k, v) in route_tags.fields.iter() {
178        let v = match &v.kind {
179            Some(protobuf::value::Kind::StringValue(v)) => v.clone(),
180            _ => {
181                return Err(Error::new_static("invalid tag"))
182                    .with_fields("filter_metadata", JUNCTION_ROUTE_TAGS)
183            }
184        };
185
186        tags.insert(k.clone(), v);
187    }
188
189    Ok(tags)
190}
191
192impl RouteRule {
193    fn from_xds_grouped(
194        name: &str,
195        action: &xds_route::route::Action,
196        routes: &[(usize, &xds_route::Route)],
197    ) -> Result<Self, Error> {
198        let mut matches = vec![];
199        for (route_idx, route) in routes {
200            if let Some(route_match) = &route.r#match {
201                let m = RouteMatch::from_xds(route_match)
202                    .with_field("match")
203                    .with_field_index("route", *route_idx)?;
204                matches.push(m);
205            }
206        }
207
208        // grab the index of the first xds Route to use in errors below. we're
209        // assuming that there will always be a non-empty vec of route_matches
210        // here.
211        let first_route_idx = routes
212            .first()
213            .map(|(idx, _)| *idx)
214            .expect("no Routes grouped with action. this is a bug in Junction");
215
216        // parse a route name into aa validated Name. also assume they're all the same.
217        let name = if !name.is_empty() {
218            Some(
219                Name::from_str(name)
220                    .with_field("name")
221                    .with_field_index("route", first_route_idx)?,
222            )
223        } else {
224            None
225        };
226
227        let action = match action {
228            xds_route::route::Action::Route(action) => action,
229            _ => {
230                return Err(Error::new_static("unsupported route action").with_field("action"))
231                    .with_field_index("route", first_route_idx)
232            }
233        };
234
235        let timeouts = RouteTimeouts::from_xds(action)?;
236        let retry = action.retry_policy.as_ref().and_then(RouteRetry::from_xds);
237        // cluster_specifier is a oneof field, so let WeightedTarget specify the
238        // field names in errors. we still need to get the index to the
239        let backends = BackendRef::from_xds(action.cluster_specifier.as_ref())
240            .with_field("action")
241            .with_field_index("route", first_route_idx)?;
242
243        Ok(RouteRule {
244            name,
245            matches,
246            retry,
247            filters: vec![],
248            timeouts,
249            backends,
250        })
251    }
252
253    pub fn to_xds(&self) -> Vec<xds_route::Route> {
254        // retry policy
255        let mut retry_policy = self.retry.as_ref().map(RouteRetry::to_xds);
256
257        // timeouts
258        //
259        // the overall timeout gets set on the route action and the per-try
260        // timeout gets set on the policy itself. have to create a default
261        // policy if we don't have one already.
262        let (timeout, per_try_timeout) = self
263            .timeouts
264            .as_ref()
265            .map(RouteTimeouts::to_xds)
266            .unwrap_or((None, None));
267
268        if let Some(per_try_timeout) = per_try_timeout {
269            retry_policy
270                .get_or_insert_with(Default::default)
271                .per_try_timeout = Some(per_try_timeout);
272        }
273
274        let cluster_specifier = BackendRef::to_xds(&self.backends);
275
276        // tie it all together into a route action that we can use for each match
277        let route_action = xds_route::route::Action::Route(xds_route::RouteAction {
278            timeout,
279            retry_policy,
280            cluster_specifier,
281            ..Default::default()
282        });
283
284        let name = self
285            .name
286            .as_ref()
287            .map(|name| name.to_string())
288            .unwrap_or_default();
289
290        if self.matches.is_empty() {
291            vec![xds_route::Route {
292                name,
293                r#match: Some(xds_route::RouteMatch {
294                    path_specifier: Some(xds_route::route_match::PathSpecifier::Prefix(
295                        "".to_string(),
296                    )),
297                    ..Default::default()
298                }),
299                action: Some(route_action),
300                ..Default::default()
301            }]
302        } else {
303            self.matches
304                .iter()
305                .map(|route_match| {
306                    let r#match = Some(route_match.to_xds());
307                    xds_route::Route {
308                        name: name.clone(),
309                        r#match,
310                        action: Some(route_action.clone()),
311                        ..Default::default()
312                    }
313                })
314                .collect()
315        }
316    }
317}
318
319impl RouteTimeouts {
320    pub fn from_xds(r: &xds_route::RouteAction) -> Result<Option<Self>, Error> {
321        let request = r.timeout.map(Duration::try_from).transpose()?;
322        let backend_request = r
323            .retry_policy
324            .as_ref()
325            .and_then(|retry_policy| retry_policy.per_try_timeout.map(Duration::try_from))
326            .transpose()?;
327
328        if request.is_some() || backend_request.is_some() {
329            Ok(Some(RouteTimeouts {
330                request,
331                backend_request,
332            }))
333        } else {
334            Ok(None)
335        }
336    }
337
338    pub fn to_xds(
339        &self,
340    ) -> (
341        Option<google::protobuf::Duration>,
342        Option<google::protobuf::Duration>,
343    ) {
344        let request_timeout = self.request.map(|d| d.try_into().unwrap());
345        let per_try_timeout = self.backend_request.map(|d| d.try_into().unwrap());
346        (request_timeout, per_try_timeout)
347    }
348}
349
350impl RouteMatch {
351    pub fn from_xds(r: &xds_route::RouteMatch) -> Result<Self, Error> {
352        // NOTE: because path_specifier is a oneof, each individual branch has
353        // its own field name, so we don't add with_field(..) to the error.
354        let path = r
355            .path_specifier
356            .as_ref()
357            .map(PathMatch::from_xds)
358            .transpose()?;
359
360        // with xds, any method match is converted into a match on a ":method"
361        // header. it does not have a way of specifying a method match
362        // otherwise. to keep the "before and after" xDS as similar as possible
363        // then we pull this out of the headers list if it exists
364        let mut method: Option<Method> = None;
365        let mut headers = vec![];
366        for (i, header) in r.headers.iter().enumerate() {
367            let header_match = HeaderMatch::from_xds(header).with_field_index("headers", i)?;
368
369            match header_match {
370                HeaderMatch::Exact { name, value } if name == ":method" => {
371                    method = Some(value);
372                }
373                _ => {
374                    headers.push(header_match);
375                }
376            }
377        }
378
379        // query parameters get their own field name, the oneof happens inside
380        // the array of matches.
381        let query_params = r
382            .query_parameters
383            .iter()
384            .enumerate()
385            .map(|(i, e)| QueryParamMatch::from_xds(e).with_field_index("query_parameters", i))
386            .collect::<Result<Vec<_>, _>>()?;
387
388        Ok(RouteMatch {
389            headers,
390            method,
391            path,
392            query_params,
393        })
394    }
395
396    fn to_xds(&self) -> xds_route::RouteMatch {
397        // path
398        let path_specifier = self.path.as_ref().map(|p| p.to_xds());
399
400        let mut headers = vec![];
401        // method
402        if let Some(method) = &self.method {
403            headers.push(xds_route::HeaderMatcher {
404                name: ":method".to_string(),
405                header_match_specifier: Some(
406                    xds_route::header_matcher::HeaderMatchSpecifier::ExactMatch(method.to_string()),
407                ),
408                ..Default::default()
409            })
410        }
411        // headers
412        for header_match in &self.headers {
413            headers.push(header_match.to_xds());
414        }
415
416        //query
417        let query_parameters = self
418            .query_params
419            .iter()
420            .map(QueryParamMatch::to_xds)
421            .collect();
422
423        xds_route::RouteMatch {
424            headers,
425            path_specifier,
426            query_parameters,
427            ..Default::default()
428        }
429    }
430}
431
432impl QueryParamMatch {
433    pub fn from_xds(matcher: &xds_route::QueryParameterMatcher) -> Result<Self, Error> {
434        let name = matcher.name.clone();
435        match matcher.query_parameter_match_specifier.as_ref() {
436            Some(QueryParameterMatchSpecifier::StringMatch(s)) => {
437                let match_pattern = match s.match_pattern.as_ref() {
438                    Some(MatchPattern::Exact(s)) => Ok(QueryParamMatch::Exact {
439                        name,
440                        value: s.clone(),
441                    }),
442                    Some(MatchPattern::SafeRegex(pfx)) => Ok(QueryParamMatch::RegularExpression {
443                        name,
444                        value: parse_xds_regex(pfx)?,
445                    }),
446                    Some(_) => Err(Error::new_static("unsupported string match type")),
447                    None => Err(Error::new_static("missing string match")),
448                };
449                match_pattern.with_field("string_match")
450            }
451            Some(QueryParameterMatchSpecifier::PresentMatch(true)) => {
452                Ok(QueryParamMatch::RegularExpression {
453                    name,
454                    value: Regex::from_str(".*").unwrap(),
455                })
456            }
457            Some(QueryParameterMatchSpecifier::PresentMatch(false)) => {
458                Err(Error::new_static("absent matches are not supported")
459                    .with_field("present_match"))
460            }
461            // this isn't specified in the documentation, but the envoy code seems to
462            // tolerate a missing value here.
463            //
464            // https://github.com/envoyproxy/envoy/blob/main/source/common/router/config_utility.cc#L18-L57
465            None => Ok(QueryParamMatch::RegularExpression {
466                name,
467                value: Regex::from_str(".*").unwrap(),
468            }),
469        }
470    }
471
472    pub fn to_xds(&self) -> xds_route::QueryParameterMatcher {
473        let (name, matcher) = match self {
474            QueryParamMatch::RegularExpression { name, value } => {
475                let name = name.clone();
476                let matcher = MatchPattern::SafeRegex(regex_matcher(value));
477                (name, matcher)
478            }
479            QueryParamMatch::Exact { name, value } => {
480                let name = name.clone();
481                let matcher = MatchPattern::Exact(value.to_string());
482                (name, matcher)
483            }
484        };
485
486        xds_route::QueryParameterMatcher {
487            name,
488            query_parameter_match_specifier: Some(QueryParameterMatchSpecifier::StringMatch(
489                StringMatcher {
490                    match_pattern: Some(matcher),
491                    ignore_case: false,
492                },
493            )),
494        }
495    }
496}
497
498impl HeaderMatch {
499    fn from_xds(header_matcher: &xds_route::HeaderMatcher) -> Result<Self, Error> {
500        use xds_route::header_matcher::HeaderMatchSpecifier;
501
502        let name = header_matcher.name.clone();
503        match header_matcher.header_match_specifier.as_ref() {
504            Some(HeaderMatchSpecifier::ExactMatch(value)) => Ok(HeaderMatch::Exact {
505                name,
506                value: value.clone(),
507            }),
508            Some(HeaderMatchSpecifier::SafeRegexMatch(regex)) => {
509                Ok(HeaderMatch::RegularExpression {
510                    name,
511                    value: parse_xds_regex(regex)?,
512                })
513            }
514            // we can support present matches but not absent matches
515            Some(HeaderMatchSpecifier::PresentMatch(true)) => Ok(HeaderMatch::RegularExpression {
516                name,
517                value: Regex::from_str(".*").unwrap(),
518            }),
519            Some(_) => Err(Error::new_static("unsupported matcher")),
520            None => Ok(HeaderMatch::RegularExpression {
521                name,
522                value: Regex::from_str(".*").unwrap(),
523            }),
524        }
525    }
526
527    fn to_xds(&self) -> xds_route::HeaderMatcher {
528        match self {
529            HeaderMatch::RegularExpression { name, value } => xds_route::HeaderMatcher {
530                name: name.clone(),
531                header_match_specifier: Some(
532                    xds_route::header_matcher::HeaderMatchSpecifier::SafeRegexMatch(regex_matcher(
533                        value,
534                    )),
535                ),
536                ..Default::default()
537            },
538            HeaderMatch::Exact { name, value } => xds_route::HeaderMatcher {
539                name: name.clone(),
540                header_match_specifier: Some(
541                    xds_route::header_matcher::HeaderMatchSpecifier::ExactMatch(value.to_string()),
542                ),
543                ..Default::default()
544            },
545        }
546    }
547}
548
549impl PathMatch {
550    fn from_xds(path_spec: &xds_route::route_match::PathSpecifier) -> Result<Self, Error> {
551        match path_spec {
552            xds_route::route_match::PathSpecifier::Prefix(p) => {
553                Ok(PathMatch::Prefix { value: p.clone() })
554            }
555            xds_route::route_match::PathSpecifier::Path(p) => {
556                Ok(PathMatch::Exact { value: p.clone() })
557            }
558            xds_route::route_match::PathSpecifier::SafeRegex(p) => {
559                Ok(PathMatch::RegularExpression {
560                    value: parse_xds_regex(p).with_field("safe_regex")?,
561                })
562            }
563            _ => Err(Error::new_static("unsupported path specifier")),
564        }
565    }
566
567    pub fn to_xds(&self) -> xds_route::route_match::PathSpecifier {
568        match self {
569            PathMatch::Prefix { value } => {
570                xds_route::route_match::PathSpecifier::Prefix(value.to_string())
571            }
572            PathMatch::RegularExpression { value } => {
573                xds_route::route_match::PathSpecifier::SafeRegex(regex_matcher(value))
574            }
575            PathMatch::Exact { value } => {
576                xds_route::route_match::PathSpecifier::Path(value.clone())
577            }
578        }
579    }
580}
581
582impl RouteRetry {
583    pub fn from_xds(r: &xds_route::RetryPolicy) -> Option<Self> {
584        if r.retriable_status_codes.is_empty()
585            && r.num_retries.is_none()
586            && r.retry_back_off.is_none()
587        {
588            //need to do this as some of the timeout logic is carried in the same proto
589            return None;
590        }
591        let codes = r
592            .retriable_status_codes
593            .iter()
594            .map(|code| *code as u16)
595            .collect();
596        let attempts = r.num_retries.map(|v| u32::from(v) + 1);
597        let backoff = r
598            .retry_back_off
599            .as_ref()
600            .and_then(|r2| r2.base_interval.map(|x| x.try_into().unwrap()));
601        Some(Self {
602            codes,
603            attempts,
604            backoff,
605        })
606    }
607
608    pub fn to_xds(&self) -> xds_route::RetryPolicy {
609        let retriable_status_codes = self.codes.iter().map(|&code| code as u32).collect();
610        let num_retries = self
611            .attempts
612            .map(|attempts| UInt32Value::from(attempts.saturating_sub(1)));
613
614        let retry_back_off = self.backoff.map(|b| xds_route::retry_policy::RetryBackOff {
615            base_interval: Some(b.try_into().unwrap()),
616            max_interval: None,
617        });
618
619        xds_route::RetryPolicy {
620            retriable_status_codes,
621            num_retries,
622            retry_back_off,
623            ..Default::default()
624        }
625    }
626}
627
628/// Group an iterator of `(k, v)` pairs together by `k`. Returns an iterator
629/// over `(K, Vec<V>)` pairs.
630///
631/// Like `uniq`, only groups consecutively unique items together.
632///
633/// `group_by` should never emit an item with an empty Vec - to have a key,
634/// there must have also been a value.
635fn group_by<I, K, V>(iter: I) -> GroupBy<<I as IntoIterator>::IntoIter, K, V>
636where
637    I: IntoIterator<Item = (K, V)>,
638    K: PartialEq,
639{
640    GroupBy {
641        iter: iter.into_iter(),
642        current_key: None,
643        current_values: Vec::new(),
644    }
645}
646
647struct GroupBy<I, K, V> {
648    iter: I,
649    current_key: Option<K>,
650    current_values: Vec<V>,
651}
652
653impl<I, K, V> Iterator for GroupBy<I, K, V>
654where
655    I: Iterator<Item = (K, V)>,
656    K: PartialEq + Debug,
657    V: Debug,
658{
659    type Item = (K, Vec<V>);
660
661    fn next(&mut self) -> Option<Self::Item> {
662        loop {
663            match (self.current_key.take(), self.iter.next()) {
664                // data, no previous group
665                (None, Some((k, v))) => {
666                    self.current_key = Some(k);
667                    self.current_values.push(v);
668                }
669                // add to the previous group
670                (Some(current_key), Some((next_key, v))) if next_key == current_key => {
671                    self.current_key = Some(current_key);
672                    self.current_values.push(v)
673                }
674                // emit the previous group, add a new group
675                (Some(current_key), Some((next_key, v))) => {
676                    let values = std::mem::take(&mut self.current_values);
677
678                    // save the next key and value
679                    self.current_key = Some(next_key);
680                    self.current_values.push(v);
681
682                    return Some((current_key, values));
683                }
684                // the iterator is done, but the last group hasn't been emitted.
685                (Some(key), None) => {
686                    let values = std::mem::take(&mut self.current_values);
687                    return Some((key, values));
688                }
689                // everyone is done
690                (None, None) => return None,
691            }
692        }
693    }
694}
695
696impl BackendRef {
697    pub(crate) fn to_xds(wbs: &[Self]) -> Option<xds_route::route_action::ClusterSpecifier> {
698        match wbs {
699            [] => None,
700            [backend] => Some(xds_route::route_action::ClusterSpecifier::Cluster(
701                backend.name(),
702            )),
703            targets => {
704                let clusters = targets
705                    .iter()
706                    .map(|wb| xds_route::weighted_cluster::ClusterWeight {
707                        name: wb.name(),
708                        weight: Some(wb.weight.into()),
709                        ..Default::default()
710                    })
711                    .collect();
712                Some(xds_route::route_action::ClusterSpecifier::WeightedClusters(
713                    xds_route::WeightedCluster {
714                        clusters,
715                        ..Default::default()
716                    },
717                ))
718            }
719        }
720    }
721
722    pub(crate) fn from_xds(
723        xds: Option<&xds_route::route_action::ClusterSpecifier>,
724    ) -> Result<Vec<Self>, Error> {
725        match xds {
726            Some(xds_route::route_action::ClusterSpecifier::Cluster(name)) => {
727                BackendRef::from_str(name)
728                    .map(|br| vec![br])
729                    .with_field("cluster")
730            }
731            Some(xds_route::route_action::ClusterSpecifier::WeightedClusters(
732                weighted_clusters,
733            )) => {
734                let clusters = weighted_clusters.clusters.iter().enumerate().map(|(i, w)| {
735                    let backend_ref = BackendRef::from_str(&w.name).with_field_index("name", i)?;
736                    let weight = crate::value_or_default!(w.weight, 1);
737                    Ok(Self {
738                        weight,
739                        ..backend_ref
740                    })
741                });
742
743                clusters
744                    .collect::<Result<Vec<_>, _>>()
745                    .with_fields("weighted_clusters", "clusters")
746            }
747            Some(_) => Err(Error::new_static("unsupported cluster specifier")),
748            None => Ok(Vec::new()),
749        }
750    }
751}
752
753#[cfg(test)]
754mod test {
755    use super::*;
756    use crate::{http::HostnameMatch, Hostname, Service};
757
758    #[test]
759    fn test_group_by() {
760        let groups: Vec<_> = group_by([(1, "a"), (2, "b"), (3, "c")]).collect();
761        assert_eq!(vec![(1, vec!["a"]), (2, vec!["b"]), (3, vec!["c"])], groups);
762
763        let groups: Vec<_> = group_by([
764            (1, "a"),
765            (1, "a"),
766            (2, "b"),
767            (3, "c"),
768            (3, "c"),
769            (3, "c"),
770            (1, "a"),
771        ])
772        .collect();
773        assert_eq!(
774            vec![
775                (1, vec!["a", "a"]),
776                (2, vec!["b"]),
777                (3, vec!["c", "c", "c"]),
778                (1, vec!["a"]),
779            ],
780            groups
781        );
782    }
783
784    #[test]
785    fn test_simple_route() {
786        let web = Service::kube("prod", "web").unwrap();
787
788        let original = Route {
789            id: Name::from_static("my-route"),
790            hostnames: vec![web.hostname().into()],
791            ports: vec![],
792            tags: Default::default(),
793            rules: vec![RouteRule {
794                backends: vec![BackendRef {
795                    weight: 1,
796                    service: web.clone(),
797                    port: None,
798                }],
799                ..Default::default()
800            }],
801        };
802
803        let round_tripped = Route::from_xds(&original.to_xds()).unwrap();
804        let expected = Route {
805            id: Name::from_static("my-route"),
806            hostnames: vec![web.hostname().into()],
807            ports: vec![],
808            tags: Default::default(),
809            rules: vec![RouteRule {
810                matches: vec![RouteMatch {
811                    path: Some(PathMatch::empty_prefix()),
812                    ..Default::default()
813                }],
814                backends: vec![BackendRef {
815                    weight: 1,
816                    service: web.clone(),
817                    port: None,
818                }],
819                ..Default::default()
820            }],
821        };
822        assert_eq!(round_tripped, expected)
823    }
824
825    #[test]
826    fn test_wildcard_hostname() {
827        let web = Service::kube("prod", "web").unwrap();
828
829        let original = Route {
830            id: Name::from_static("my-route"),
831            hostnames: vec![
832                HostnameMatch::from_str("*.prod.web.svc.cluster.local").unwrap(),
833                HostnameMatch::from_str("*.staging.web.svc.cluster.local").unwrap(),
834            ],
835            ports: vec![80, 443],
836            tags: Default::default(),
837            rules: vec![RouteRule {
838                matches: vec![RouteMatch {
839                    path: Some(PathMatch::empty_prefix()),
840                    ..Default::default()
841                }],
842                backends: vec![BackendRef {
843                    weight: 1,
844                    service: web.clone(),
845                    port: None,
846                }],
847                ..Default::default()
848            }],
849        };
850
851        let round_tripped = Route::from_xds(&original.to_xds()).unwrap();
852        assert_eq!(round_tripped, original)
853    }
854
855    #[test]
856    fn test_route_no_rules() {
857        let original = Route {
858            id: Name::from_static("no-rules"),
859            hostnames: vec![Hostname::from_static("web.internal").into()],
860            ports: vec![],
861            tags: Default::default(),
862            rules: vec![],
863        };
864
865        let round_tripped = Route::from_xds(&original.to_xds()).unwrap();
866        assert_eq!(round_tripped, original)
867    }
868
869    #[test]
870    fn test_route_rule_no_backend() {
871        let original = Route {
872            id: Name::from_static("no-backends"),
873            hostnames: vec![Hostname::from_static("web.internal").into()],
874            ports: vec![],
875            tags: Default::default(),
876            rules: vec![RouteRule::default()],
877        };
878        let normalized = Route {
879            id: Name::from_static("no-backends"),
880            hostnames: vec![Hostname::from_static("web.internal").into()],
881            ports: vec![],
882            tags: Default::default(),
883            rules: vec![RouteRule {
884                matches: vec![RouteMatch {
885                    path: Some(PathMatch::Prefix {
886                        value: "".to_string(),
887                    }),
888                    ..Default::default()
889                }],
890                ..Default::default()
891            }],
892        };
893
894        let round_tripped = Route::from_xds(&original.to_xds()).unwrap();
895        assert_eq!(round_tripped, normalized)
896    }
897
898    #[test]
899    fn test_metadata_roundtrip() {
900        let web = Service::kube("prod", "web").unwrap();
901
902        assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
903            id: Name::from_static("metadata"),
904            hostnames: vec![Hostname::from_static("web.internal").into()],
905            ports: vec![],
906            tags: BTreeMap::from_iter([("foo".to_string(), "bar".to_string())]),
907            rules: vec![RouteRule {
908                matches: vec![RouteMatch {
909                    path: Some(PathMatch::Prefix {
910                        value: "".to_string(),
911                    }),
912                    ..Default::default()
913                }],
914                backends: vec![BackendRef {
915                    weight: 1,
916                    service: web.clone(),
917                    port: Some(8778),
918                }],
919                ..Default::default()
920            }],
921        });
922    }
923
924    #[test]
925    fn test_multiple_rules_roundtrip() {
926        let web = Service::kube("prod", "web").unwrap();
927        let staging = Service::kube("staging", "web").unwrap();
928
929        // should roundtrip with different targets
930        assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
931            id: Name::from_static("multiple-targets"),
932            hostnames: vec![Hostname::from_static("web.internal").into()],
933            ports: vec![],
934            tags: Default::default(),
935            rules: vec![
936                RouteRule {
937                    name: Some(Name::from_static("split-web")),
938                    matches: vec![RouteMatch {
939                        path: Some(PathMatch::Exact {
940                            value: "/foo/feature-test".to_string(),
941                        }),
942                        ..Default::default()
943                    }],
944                    backends: vec![
945                        BackendRef {
946                            weight: 3,
947                            service: staging.clone(),
948                            port: Some(80),
949                        },
950                        BackendRef {
951                            weight: 1,
952                            service: web.clone(),
953                            port: Some(80),
954                        },
955                    ],
956                    ..Default::default()
957                },
958                RouteRule {
959                    name: Some(Name::from_static("one-web")),
960                    matches: vec![RouteMatch {
961                        path: Some(PathMatch::Prefix {
962                            value: "/foo".to_string(),
963                        }),
964                        ..Default::default()
965                    }],
966                    backends: vec![BackendRef {
967                        weight: 1,
968                        service: web.clone(),
969                        port: Some(80),
970                    }],
971                    ..Default::default()
972                },
973            ],
974        });
975
976        // should roundtrip with the same backends but different timeouts
977        assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
978            id: Name::from_static("same-target-multiple-timeouts"),
979            hostnames: vec![Hostname::from_static("web.internal").into()],
980            ports: vec![],
981            tags: Default::default(),
982            rules: vec![
983                RouteRule {
984                    name: Some(Name::from_static("no-timeouts")),
985                    matches: vec![RouteMatch {
986                        path: Some(PathMatch::Exact {
987                            value: "/foo/feature-test".to_string(),
988                        }),
989                        ..Default::default()
990                    }],
991                    backends: vec![BackendRef {
992                        weight: 1,
993                        service: web.clone(),
994                        port: None,
995                    }],
996                    ..Default::default()
997                },
998                RouteRule {
999                    name: Some(Name::from_static("with-timeouts")),
1000                    matches: vec![RouteMatch {
1001                        path: Some(PathMatch::Prefix {
1002                            value: "/foo".to_string(),
1003                        }),
1004                        ..Default::default()
1005                    }],
1006                    timeouts: Some(RouteTimeouts {
1007                        request: Some(Duration::from_secs(123)),
1008                        backend_request: None,
1009                    }),
1010                    backends: vec![BackendRef {
1011                        weight: 1,
1012                        service: web.clone(),
1013                        port: None,
1014                    }],
1015                    ..Default::default()
1016                },
1017            ],
1018        });
1019
1020        // should roundtrip with the same backends but different retries
1021        assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
1022            id: Name::from_static("same-target-multiple-retries"),
1023            hostnames: vec![Hostname::from_static("web.internal").into()],
1024            ports: vec![],
1025            tags: Default::default(),
1026            rules: vec![
1027                RouteRule {
1028                    matches: vec![RouteMatch {
1029                        path: Some(PathMatch::Exact {
1030                            value: "/foo/feature-test".to_string(),
1031                        }),
1032                        ..Default::default()
1033                    }],
1034                    retry: Some(RouteRetry {
1035                        codes: vec![500, 503],
1036                        attempts: Some(123),
1037                        backoff: Some(Duration::from_secs(1)),
1038                    }),
1039                    backends: vec![BackendRef {
1040                        weight: 1,
1041                        service: web.clone(),
1042                        port: None,
1043                    }],
1044                    ..Default::default()
1045                },
1046                RouteRule {
1047                    matches: vec![RouteMatch {
1048                        path: Some(PathMatch::Prefix {
1049                            value: "/foo".to_string(),
1050                        }),
1051                        ..Default::default()
1052                    }],
1053                    backends: vec![BackendRef {
1054                        weight: 1,
1055                        service: web.clone(),
1056                        port: None,
1057                    }],
1058                    ..Default::default()
1059                },
1060            ],
1061        });
1062
1063        // should roundtrip with the same everything but different names
1064        assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
1065            id: Name::from_static("different-names"),
1066            hostnames: vec![Hostname::from_static("web.internal").into()],
1067            ports: vec![],
1068            tags: Default::default(),
1069            rules: vec![
1070                RouteRule {
1071                    name: Some(Name::from_static("rule-1")),
1072                    matches: vec![RouteMatch {
1073                        path: Some(PathMatch::Prefix {
1074                            value: "/foo".to_string(),
1075                        }),
1076                        ..Default::default()
1077                    }],
1078                    backends: vec![BackendRef {
1079                        weight: 1,
1080                        service: web.clone(),
1081                        port: None,
1082                    }],
1083                    ..Default::default()
1084                },
1085                RouteRule {
1086                    name: Some(Name::from_static("rule-2")),
1087                    matches: vec![RouteMatch {
1088                        path: Some(PathMatch::Prefix {
1089                            value: "/foo".to_string(),
1090                        }),
1091                        ..Default::default()
1092                    }],
1093                    backends: vec![BackendRef {
1094                        weight: 1,
1095                        service: web.clone(),
1096                        port: None,
1097                    }],
1098                    ..Default::default()
1099                },
1100            ],
1101        });
1102    }
1103
1104    #[test]
1105    fn test_condense_rules() {
1106        let web = Service::kube("prod", "web").unwrap();
1107
1108        // should be condensed - the rules have the same everything except matches
1109        let original = Route {
1110            id: Name::from_static("will-be-condensed"),
1111            hostnames: vec![Hostname::from_static("web.internal").into()],
1112            ports: vec![],
1113            tags: Default::default(),
1114            rules: vec![
1115                RouteRule {
1116                    matches: vec![RouteMatch {
1117                        path: Some(PathMatch::Exact {
1118                            value: "/foo/feature-test".to_string(),
1119                        }),
1120                        ..Default::default()
1121                    }],
1122                    backends: vec![BackendRef {
1123                        weight: 1,
1124                        service: web.clone(),
1125                        port: Some(80),
1126                    }],
1127                    ..Default::default()
1128                },
1129                RouteRule {
1130                    matches: vec![RouteMatch {
1131                        path: Some(PathMatch::Prefix {
1132                            value: "/foo".to_string(),
1133                        }),
1134                        ..Default::default()
1135                    }],
1136                    backends: vec![BackendRef {
1137                        weight: 1,
1138                        service: web.clone(),
1139                        port: Some(80),
1140                    }],
1141                    ..Default::default()
1142                },
1143            ],
1144        };
1145
1146        // should condense rules if the backends and retries/etc are identical
1147        let converted = Route::from_xds(&original.to_xds()).unwrap();
1148        assert_eq!(
1149            converted,
1150            Route {
1151                id: Name::from_static("will-be-condensed"),
1152                hostnames: vec![Hostname::from_static("web.internal").into()],
1153                ports: vec![],
1154                tags: Default::default(),
1155                rules: vec![RouteRule {
1156                    matches: vec![
1157                        RouteMatch {
1158                            path: Some(PathMatch::Exact {
1159                                value: "/foo/feature-test".to_string(),
1160                            }),
1161                            ..Default::default()
1162                        },
1163                        RouteMatch {
1164                            path: Some(PathMatch::Prefix {
1165                                value: "/foo".to_string(),
1166                            }),
1167                            ..Default::default()
1168                        }
1169                    ],
1170                    backends: vec![BackendRef {
1171                        weight: 1,
1172                        service: web.clone(),
1173                        port: Some(80),
1174                    }],
1175                    ..Default::default()
1176                },],
1177            }
1178        )
1179    }
1180
1181    #[test]
1182    fn test_multiple_matches_roundtrip() {
1183        let web = Service::kube("prod", "web").unwrap();
1184
1185        assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
1186            id: Name::from_static("multiple-matches"),
1187            hostnames: vec![Hostname::from_static("web.internal").into()],
1188            ports: vec![],
1189            tags: Default::default(),
1190            rules: vec![RouteRule {
1191                matches: vec![
1192                    RouteMatch {
1193                        path: Some(PathMatch::Prefix {
1194                            value: "/foo".to_string(),
1195                        }),
1196                        ..Default::default()
1197                    },
1198                    RouteMatch {
1199                        path: Some(PathMatch::Prefix {
1200                            value: "/bar".to_string(),
1201                        }),
1202                        ..Default::default()
1203                    },
1204                    RouteMatch {
1205                        query_params: vec![QueryParamMatch::Exact {
1206                            name: "param".to_string(),
1207                            value: "an_value".to_string(),
1208                        }],
1209                        ..Default::default()
1210                    },
1211                ],
1212                backends: vec![BackendRef {
1213                    weight: 1,
1214                    service: web.clone(),
1215                    port: Some(80),
1216                }],
1217                ..Default::default()
1218            }],
1219        });
1220    }
1221
1222    #[test]
1223    fn test_full_route_match_roundtrips() {
1224        let web = Service::kube("prod", "web").unwrap();
1225
1226        assert_roundtrip::<_, xds_route::RouteConfiguration>(Route {
1227            id: Name::from_static("full-send"),
1228            hostnames: vec![
1229                HostnameMatch::from_str("*.web.internal").unwrap(),
1230                Hostname::from_static("potato.tomato").into(),
1231                Hostname::from_static("web.internal").into(),
1232            ],
1233            ports: vec![80, 443, 8080],
1234            tags: [("foo".to_string(), "bar".to_string())]
1235                .into_iter()
1236                .collect(),
1237            rules: vec![RouteRule {
1238                matches: vec![RouteMatch {
1239                    path: Some(PathMatch::Prefix {
1240                        value: "/potato".to_string(),
1241                    }),
1242                    headers: vec![HeaderMatch::RegularExpression {
1243                        name: "x-one".to_string(),
1244                        value: ".*".parse().unwrap(),
1245                    }],
1246                    query_params: vec![
1247                        QueryParamMatch::RegularExpression {
1248                            name: "foo".to_string(),
1249                            value: r"\w+".parse().unwrap(),
1250                        },
1251                        QueryParamMatch::Exact {
1252                            name: "bar".to_string(),
1253                            value: "baz".to_string(),
1254                        },
1255                    ],
1256                    method: Some("CONNECT".to_string()),
1257                }],
1258                backends: vec![
1259                    BackendRef {
1260                        weight: 1,
1261                        service: web.clone(),
1262                        port: Some(8080),
1263                    },
1264                    BackendRef {
1265                        weight: 0,
1266                        service: web.clone(),
1267                        port: None,
1268                    },
1269                ],
1270                ..Default::default()
1271            }],
1272        });
1273    }
1274
1275    #[track_caller]
1276    fn assert_roundtrip<T, Xds>(v: T)
1277    where
1278        T: PartialEq + std::fmt::Debug,
1279        for<'a> &'a T: Into<Xds>,
1280        for<'a> &'a Xds: TryInto<T, Error = Error>,
1281    {
1282        let xds: Xds = (&v).into();
1283        let back: T = (&xds).try_into().unwrap();
1284        assert_eq!(v, back);
1285    }
1286}