junction_api/kube/
http.rs

1use std::collections::{BTreeMap, HashSet};
2use std::str::FromStr;
3
4use gateway_api::apis::experimental::httproutes as gateway_http;
5use kube::api::ObjectMeta;
6use kube::{Resource, ResourceExt};
7
8use crate::error::{Error, ErrorContext};
9use crate::http::{
10    BackendRef, HeaderMatch, HostnameMatch, PathMatch, QueryParamMatch, Route, RouteMatch,
11    RouteRetry, RouteRule, RouteTimeouts,
12};
13use crate::shared::Regex;
14use crate::{Duration, Name, Service};
15
16macro_rules! option_from_gateway {
17    ($t:ty, $field:expr, $field_name:literal) => {
18        $field
19            .as_ref()
20            .map(|v| <$t>::from_gateway(v).with_field($field_name))
21            .transpose()
22    };
23}
24
25macro_rules! vec_from_gateway {
26    ($t:ty, $opt_vec:expr, $field_name:literal) => {
27        $opt_vec
28            .iter()
29            .flatten()
30            .enumerate()
31            .map(|(i, e)| <$t>::from_gateway(e).with_field_index($field_name, i))
32            .collect::<Result<Vec<_>, _>>()
33    };
34    ($t:ty, $opt_vec:expr, $field_name:literal, $param:expr) => {
35        $opt_vec
36            .iter()
37            .flatten()
38            .enumerate()
39            .map(|(i, e)| <$t>::from_gateway($param, e).with_field_index($field_name, i))
40            .collect::<Result<Vec<_>, _>>()
41    };
42}
43
44macro_rules! option_to_gateway {
45    ($field:expr) => {
46        $field.as_ref().map(|e| e.to_gateway())
47    };
48}
49
50macro_rules! vec_to_gateway {
51    ($vec:expr) => {
52        $vec.iter().map(|e| e.to_gateway()).collect()
53    };
54    ($vec:expr, err_field = $field_name:literal) => {
55        $vec.iter()
56            .enumerate()
57            .map(|(i, e)| e.to_gateway().with_field_index($field_name, i))
58            .collect::<Result<Vec<_>, Error>>()
59    };
60}
61
62impl Route {
63    /// Convert a Gateway API [HTTPRoute][gateway_http::HTTPRoute] into a Junction [Route].
64    pub fn from_gateway_httproute(httproute: &gateway_http::HTTPRoute) -> Result<Route, Error> {
65        let id = Name::from_str(httproute.meta().name.as_ref().unwrap()).unwrap();
66        let namespace = httproute.meta().namespace.as_deref().unwrap_or("default");
67        let (hostnames, ports) = from_parent_refs(
68            namespace,
69            httproute.spec.parent_refs.as_deref().unwrap_or_default(),
70        )?;
71        let tags = read_tags(httproute.annotations());
72
73        let mut rules = vec_from_gateway!(RouteRule, httproute.spec.rules, "rules", namespace)
74            .with_field("spec")?;
75        // reverse sort rules
76        rules.sort_by(|a, b| b.cmp(a));
77
78        Ok(Self {
79            id,
80            hostnames,
81            ports,
82            tags,
83            rules,
84        })
85    }
86
87    /// Convert this Route to a Gateway API [HTTPRoute][gateway_http::HTTPRoute].
88    pub fn to_gateway_httproute(&self, namespace: &str) -> Result<gateway_http::HTTPRoute, Error> {
89        let parent_refs = Some(to_parent_refs(&self.hostnames, &self.ports)?);
90
91        let spec = gateway_http::HTTPRouteSpec {
92            hostnames: None,
93            parent_refs,
94            rules: Some(vec_to_gateway!(self.rules, err_field = "rules")?),
95        };
96
97        let mut route = gateway_http::HTTPRoute {
98            metadata: ObjectMeta {
99                namespace: Some(namespace.to_string()),
100                name: Some(self.id.to_string()),
101                ..Default::default()
102            },
103            spec,
104            status: None,
105        };
106        write_tags(route.annotations_mut(), &self.tags);
107
108        Ok(route)
109    }
110}
111
112fn from_parent_refs(
113    route_namespace: &str,
114    parent_refs: &[gateway_http::HTTPRouteParentRefs],
115) -> Result<(Vec<HostnameMatch>, Vec<u16>), Error> {
116    let mut hostnames_and_ports: BTreeMap<_, HashSet<u16>> = BTreeMap::new();
117
118    for parent_ref in parent_refs {
119        let group = parent_ref
120            .group
121            .as_deref()
122            .unwrap_or("gateway.networking.k8s.io");
123        let kind = parent_ref.kind.as_deref().unwrap_or("");
124
125        let hostname = match (group, kind) {
126            ("junctionlabs.io", "DNS") => HostnameMatch::from_str(&parent_ref.name)?,
127            ("", "Service") => {
128                let namespace = parent_ref.namespace.as_deref().unwrap_or(route_namespace);
129
130                // generate the kube service Hostname and convert it to a match
131                crate::Service::kube(namespace, &parent_ref.name)?
132                    .hostname()
133                    .into()
134            }
135            (group, kind) => {
136                return Err(Error::new(format!(
137                    "unsupported backend ref: {group}/{kind}"
138                )))
139            }
140        };
141
142        let port_list = hostnames_and_ports.entry(hostname).or_default();
143        if let &Some(port) = &parent_ref.port {
144            let port: u16 = port
145                .try_into()
146                .map_err(|_| Error::new_static("invalid u16"))?;
147            port_list.insert(port);
148        }
149    }
150
151    if !all_same(hostnames_and_ports.values()) {
152        return Err(Error::new_static(
153            "parentRefs do not all have the same list of ports",
154        ));
155    }
156
157    // grab the first port set and turn it into a vec. we've already validated
158    // that all the port sets are the same, so only the first one matters.
159    let ports = hostnames_and_ports
160        .values()
161        .next()
162        .cloned()
163        .unwrap_or_default();
164    let mut ports: Vec<_> = ports.into_iter().collect();
165    ports.sort();
166
167    // grab the hostnames and peel out.
168    let mut hostnames: Vec<_> = hostnames_and_ports.into_keys().collect();
169    hostnames.sort();
170
171    Ok((hostnames, ports))
172}
173
174fn all_same<T: Eq>(iter: impl Iterator<Item = T>) -> bool {
175    let mut prev = None;
176    for item in iter {
177        match prev.take() {
178            Some(prev) if prev != item => return false,
179            _ => (),
180        }
181
182        prev = Some(item)
183    }
184
185    true
186}
187
188fn to_parent_refs(
189    hostnames: &[HostnameMatch],
190    ports: &[u16],
191) -> Result<Vec<gateway_http::HTTPRouteParentRefs>, Error> {
192    let mut parent_refs = Vec::with_capacity(hostnames.len() * ports.len());
193
194    for hostname in hostnames {
195        let parent_ref = hostname_match_to_parentref(hostname)?;
196
197        for &port in ports {
198            let mut parent_ref = parent_ref.clone();
199            parent_ref.port = Some(port as i32);
200            parent_refs.push(parent_ref);
201        }
202
203        if ports.is_empty() {
204            parent_refs.push(parent_ref);
205        }
206    }
207
208    Ok(parent_refs)
209}
210
211fn hostname_match_to_parentref(
212    hostname_match: &HostnameMatch,
213) -> Result<gateway_http::HTTPRouteParentRefs, Error> {
214    let (svc, name, namespace) = match hostname_match {
215        // subdomains are always treated as DNS parentRefs. this doesn't use
216        // name_and_namespace so that the wildcard makes it into the name.
217        HostnameMatch::Subdomain(hostname) => {
218            let svc = Service::Dns(crate::DnsService {
219                hostname: hostname.clone(),
220            });
221            let name = hostname_match.to_string();
222            (svc, name, None)
223        }
224        HostnameMatch::Exact(hostname) => {
225            let svc = Service::from_str(hostname)?;
226            let (name, namespace) = name_and_namespace(&svc);
227            (svc, name, namespace)
228        }
229    };
230
231    let (group, kind) = group_kind(&svc);
232    Ok(gateway_http::HTTPRouteParentRefs {
233        group: Some(group.to_string()),
234        kind: Some(kind.to_string()),
235        name,
236        namespace,
237        ..Default::default()
238    })
239}
240
241fn write_tags(annotations: &mut BTreeMap<String, String>, tags: &BTreeMap<String, String>) {
242    for (k, v) in tags {
243        let k = format!("junctionlabs.io.route.tags/{k}");
244        annotations.insert(k, v.to_string());
245    }
246}
247
248fn read_tags(annotations: &BTreeMap<String, String>) -> BTreeMap<String, String> {
249    let mut tags = BTreeMap::new();
250
251    for (k, v) in annotations {
252        if let Some(key) = k.strip_prefix("junctionlabs.io.route.tags/") {
253            tags.insert(key.to_string(), v.to_string());
254        }
255    }
256
257    tags
258}
259
260fn port_from_gateway(port: &Option<i32>) -> Result<Option<u16>, Error> {
261    (*port)
262        .map(|p| {
263            p.try_into()
264                .map_err(|_| Error::new_static("port value out of range"))
265        })
266        .transpose()
267}
268
269macro_rules! method_matches {
270    ( $($method:ident => $str:expr,)* $(,)*) => {
271        fn method_from_gateway(match_method: &gateway_http::HTTPRouteRulesMatchesMethod) -> Result<crate::http::Method, Error> {
272            match match_method {
273                $(
274                    gateway_http::HTTPRouteRulesMatchesMethod::$method => Ok($str.to_string()),
275                )*
276            }
277        }
278
279        fn method_to_gateway(method: &crate::http::Method) -> Result<gateway_http::HTTPRouteRulesMatchesMethod, Error> {
280            match method.as_str() {
281                $(
282                    $str => Ok(gateway_http::HTTPRouteRulesMatchesMethod::$method),
283                )*
284                _ => Err(Error::new(format!("unrecognized HTTP method: {}", method)))
285            }
286        }
287    }
288}
289
290method_matches! {
291    Get => "GET",
292    Head => "HEAD",
293    Post => "POST",
294    Put => "PUT",
295    Delete => "DELETE",
296    Connect => "CONNECT",
297    Options => "OPTIONS",
298    Trace => "TRACE",
299    Patch => "PATCH",
300}
301
302impl RouteRule {
303    fn to_gateway(&self) -> Result<gateway_http::HTTPRouteRules, Error> {
304        Ok(gateway_http::HTTPRouteRules {
305            name: self.name.as_ref().map(|s| s.to_string()),
306            backend_refs: Some(vec_to_gateway!(self.backends, err_field = "backends")?),
307            filters: None,
308            matches: Some(vec_to_gateway!(self.matches, err_field = "matches")?),
309            retry: option_to_gateway!(self.retry)
310                .transpose()
311                .with_field("retry")?,
312            session_persistence: None,
313            timeouts: option_to_gateway!(self.timeouts)
314                .transpose()
315                .with_field("timeouts")?,
316        })
317    }
318
319    fn from_gateway(
320        route_namespace: &str,
321        rule: &gateway_http::HTTPRouteRules,
322    ) -> Result<Self, Error> {
323        let name = rule
324            .name
325            .as_ref()
326            .map(|s| Name::from_str(s))
327            .transpose()
328            .with_field("name")?;
329
330        let matches = vec_from_gateway!(RouteMatch, rule.matches, "matches")?;
331        let timeouts = option_from_gateway!(RouteTimeouts, rule.timeouts, "timeouts")?;
332        let backends =
333            vec_from_gateway!(BackendRef, rule.backend_refs, "backends", route_namespace)?;
334        let retry = option_from_gateway!(RouteRetry, rule.retry, "retry")?;
335
336        // FIXME: filters are ignored because they're not implemented yet
337        let filters = vec![];
338
339        Ok(RouteRule {
340            name,
341            matches,
342            filters,
343            timeouts,
344            retry,
345            backends,
346        })
347    }
348}
349
350impl RouteTimeouts {
351    fn to_gateway(&self) -> Result<gateway_http::HTTPRouteRulesTimeouts, Error> {
352        let request = self.request.map(serialize_duration).transpose()?;
353        let backend_request = self.backend_request.map(serialize_duration).transpose()?;
354        Ok(gateway_http::HTTPRouteRulesTimeouts {
355            backend_request,
356            request,
357        })
358    }
359
360    fn from_gateway(timeouts: &gateway_http::HTTPRouteRulesTimeouts) -> Result<Self, Error> {
361        let request = parse_duration(&timeouts.request).with_field("request")?;
362        let backend_request =
363            parse_duration(&timeouts.backend_request).with_field("backendRequest")?;
364
365        Ok(RouteTimeouts {
366            request,
367            backend_request,
368        })
369    }
370}
371
372impl RouteRetry {
373    fn to_gateway(&self) -> Result<gateway_http::HTTPRouteRulesRetry, Error> {
374        let attempts = self.attempts.map(|n| n as i64);
375        let backoff = self.backoff.map(serialize_duration).transpose()?;
376        let codes = if self.codes.is_empty() {
377            None
378        } else {
379            let codes = self.codes.iter().map(|&code| code as i64).collect();
380            Some(codes)
381        };
382
383        Ok(gateway_http::HTTPRouteRulesRetry {
384            attempts,
385            backoff,
386            codes,
387        })
388    }
389
390    fn from_gateway(retry: &gateway_http::HTTPRouteRulesRetry) -> Result<Self, Error> {
391        let mut codes = Vec::with_capacity(retry.codes.as_ref().map_or(0, |c| c.len()));
392        for (i, &code) in retry.codes.iter().flatten().enumerate() {
393            let code: u16 = code
394                .try_into()
395                .map_err(|_| Error::new_static("invalid response code"))
396                .with_field_index("codes", i)?;
397            codes.push(code);
398        }
399
400        let attempts = retry
401            .attempts
402            .map(|i| i.try_into())
403            .transpose()
404            .map_err(|_| Error::new_static("invalid u32"))
405            .with_field("attempts")?;
406
407        let backoff = parse_duration(&retry.backoff)?;
408
409        Ok(RouteRetry {
410            codes,
411            attempts,
412            backoff,
413        })
414    }
415}
416
417impl RouteMatch {
418    fn to_gateway(&self) -> Result<gateway_http::HTTPRouteRulesMatches, Error> {
419        let method = self
420            .method
421            .as_ref()
422            .map(method_to_gateway)
423            .transpose()
424            .with_field("method")?;
425
426        Ok(gateway_http::HTTPRouteRulesMatches {
427            headers: Some(vec_to_gateway!(&self.headers)),
428            method,
429            path: option_to_gateway!(&self.path),
430            query_params: Some(vec_to_gateway!(&self.query_params)),
431        })
432    }
433
434    fn from_gateway(matches: &gateway_http::HTTPRouteRulesMatches) -> Result<Self, Error> {
435        let method = matches
436            .method
437            .as_ref()
438            .map(method_from_gateway)
439            .transpose()
440            .with_field("method")?;
441
442        Ok(RouteMatch {
443            path: option_from_gateway!(PathMatch, matches.path, "path")?,
444            headers: vec_from_gateway!(HeaderMatch, matches.headers, "headers")?,
445            query_params: vec_from_gateway!(QueryParamMatch, matches.query_params, "queryParams")?,
446            method,
447        })
448    }
449}
450
451impl QueryParamMatch {
452    fn to_gateway(&self) -> gateway_http::HTTPRouteRulesMatchesQueryParams {
453        match self {
454            QueryParamMatch::RegularExpression { name, value } => {
455                gateway_http::HTTPRouteRulesMatchesQueryParams {
456                    name: name.clone(),
457                    r#type: Some(
458                        gateway_http::HTTPRouteRulesMatchesQueryParamsType::RegularExpression,
459                    ),
460                    value: value.to_string(),
461                }
462            }
463            QueryParamMatch::Exact { name, value } => {
464                gateway_http::HTTPRouteRulesMatchesQueryParams {
465                    name: name.clone(),
466                    r#type: Some(gateway_http::HTTPRouteRulesMatchesQueryParamsType::Exact),
467                    value: value.clone(),
468                }
469            }
470        }
471    }
472
473    fn from_gateway(
474        matches_query: &gateway_http::HTTPRouteRulesMatchesQueryParams,
475    ) -> Result<Self, Error> {
476        let name = &matches_query.name;
477        let value = &matches_query.value;
478        match matches_query.r#type {
479            Some(gateway_http::HTTPRouteRulesMatchesQueryParamsType::Exact) => {
480                Ok(QueryParamMatch::Exact {
481                    name: name.clone(),
482                    value: value.clone(),
483                })
484            }
485            Some(gateway_http::HTTPRouteRulesMatchesQueryParamsType::RegularExpression) => {
486                let value = Regex::from_str(value)
487                    .map_err(|e| Error::new(format!("invalid regex: {e}")).with_field("value"))?;
488                Ok(QueryParamMatch::RegularExpression {
489                    name: name.clone(),
490                    value,
491                })
492            }
493            None => Err(Error::new_static("missing type")),
494        }
495    }
496}
497
498impl PathMatch {
499    fn to_gateway(&self) -> gateway_http::HTTPRouteRulesMatchesPath {
500        let (match_type, value) = match self {
501            PathMatch::Prefix { value } => (
502                gateway_http::HTTPRouteRulesMatchesPathType::PathPrefix,
503                value.clone(),
504            ),
505            PathMatch::RegularExpression { value } => (
506                gateway_http::HTTPRouteRulesMatchesPathType::RegularExpression,
507                value.to_string(),
508            ),
509            PathMatch::Exact { value } => (
510                gateway_http::HTTPRouteRulesMatchesPathType::Exact,
511                value.clone(),
512            ),
513        };
514
515        gateway_http::HTTPRouteRulesMatchesPath {
516            r#type: Some(match_type),
517            value: Some(value),
518        }
519    }
520
521    fn from_gateway(matches_path: &gateway_http::HTTPRouteRulesMatchesPath) -> Result<Self, Error> {
522        let Some(value) = &matches_path.value else {
523            return Err(Error::new_static("missing value"));
524        };
525
526        match matches_path.r#type {
527            Some(gateway_http::HTTPRouteRulesMatchesPathType::Exact) => Ok(PathMatch::Exact {
528                value: value.clone(),
529            }),
530            Some(gateway_http::HTTPRouteRulesMatchesPathType::PathPrefix) => {
531                Ok(PathMatch::Prefix {
532                    value: value.clone(),
533                })
534            }
535            Some(gateway_http::HTTPRouteRulesMatchesPathType::RegularExpression) => {
536                let value = Regex::from_str(value)
537                    .map_err(|e| Error::new(format!("invalid regex: {e}")).with_field("value"))?;
538                Ok(PathMatch::RegularExpression { value })
539            }
540            None => Err(Error::new_static("missing type")),
541        }
542    }
543}
544
545impl HeaderMatch {
546    fn to_gateway(&self) -> gateway_http::HTTPRouteRulesMatchesHeaders {
547        match self {
548            HeaderMatch::RegularExpression { name, value } => {
549                gateway_http::HTTPRouteRulesMatchesHeaders {
550                    name: name.clone(),
551                    r#type: Some(gateway_http::HTTPRouteRulesMatchesHeadersType::RegularExpression),
552                    value: value.to_string(),
553                }
554            }
555            HeaderMatch::Exact { name, value } => gateway_http::HTTPRouteRulesMatchesHeaders {
556                name: name.clone(),
557                r#type: Some(gateway_http::HTTPRouteRulesMatchesHeadersType::Exact),
558                value: value.clone(),
559            },
560        }
561    }
562
563    fn from_gateway(
564        matches_headers: &gateway_http::HTTPRouteRulesMatchesHeaders,
565    ) -> Result<Self, Error> {
566        let name = &matches_headers.name;
567        let value = &matches_headers.value;
568        match matches_headers.r#type {
569            Some(gateway_http::HTTPRouteRulesMatchesHeadersType::Exact) => Ok(HeaderMatch::Exact {
570                name: name.clone(),
571                value: value.clone(),
572            }),
573            Some(gateway_http::HTTPRouteRulesMatchesHeadersType::RegularExpression) => {
574                let value = Regex::from_str(value)
575                    .map_err(|e| Error::new(format!("invalid regex: {e}")).with_field("value"))?;
576                Ok(HeaderMatch::RegularExpression {
577                    name: name.clone(),
578                    value,
579                })
580            }
581            None => Err(Error::new_static("missing type")),
582        }
583    }
584}
585
586impl BackendRef {
587    fn from_gateway(
588        route_namespace: &str,
589        backend_ref: &gateway_http::HTTPRouteRulesBackendRefs,
590    ) -> Result<Self, Error> {
591        let group = backend_ref.group.as_deref().unwrap_or("");
592        let kind = backend_ref.kind.as_deref().unwrap_or("Service");
593        let weight = backend_ref
594            .weight
595            .unwrap_or(1)
596            .try_into()
597            .map_err(|_| Error::new_static("negative weight"))
598            .with_field("weight")?;
599
600        let port = port_from_gateway(&backend_ref.port)
601            .and_then(|p| p.ok_or(Error::new_static("backendRef port is required")))
602            .with_field("port")?;
603
604        let service = match (group, kind) {
605            ("junctionlabs.io", "DNS") => {
606                crate::Service::dns(&backend_ref.name).with_field("name")?
607            }
608            ("", "Service") => {
609                let namespace = backend_ref.namespace.as_deref().unwrap_or(route_namespace);
610                crate::Service::kube(namespace, &backend_ref.name)?
611            }
612            (group, kind) => {
613                return Err(Error::new(format!(
614                    "unsupported backend ref: {group}/{kind}"
615                )))
616            }
617        };
618
619        Ok(BackendRef {
620            service,
621            port: Some(port),
622            weight,
623        })
624    }
625
626    fn to_gateway(&self) -> Result<gateway_http::HTTPRouteRulesBackendRefs, Error> {
627        let (group, kind) = group_kind(&self.service);
628        let (name, namespace) = name_and_namespace(&self.service);
629        let weight = Some(
630            self.weight
631                .try_into()
632                .map_err(|_| Error::new_static("weight cannot be converted to an i32"))
633                .with_field("weight")?,
634        );
635        if self.port.is_none() {
636            return Err(Error::new_static(
637                "backendRefs must have a port set when converting to an HTTPRoute",
638            )
639            .with_field("port"));
640        }
641
642        Ok(gateway_http::HTTPRouteRulesBackendRefs {
643            name,
644            namespace,
645            group: Some(group.to_string()),
646            kind: Some(kind.to_string()),
647            port: self.port.map(|p| p as i32),
648            weight,
649            ..Default::default()
650        })
651    }
652}
653
654fn name_and_namespace(target: &Service) -> (String, Option<String>) {
655    match target {
656        Service::Dns(dns) => (dns.hostname.to_string(), None),
657        Service::Kube(svc) => (svc.name.to_string(), Some(svc.namespace.to_string())),
658    }
659}
660
661fn group_kind(target: &Service) -> (&'static str, &'static str) {
662    match target {
663        Service::Dns(_) => ("junctionlabs.io", "DNS"),
664        Service::Kube(_) => ("", "Service"),
665    }
666}
667
668fn parse_duration(d: &Option<String>) -> Result<Option<crate::shared::Duration>, Error> {
669    use gateway_api::duration::Duration as GatewayDuration;
670
671    let Some(d) = d else {
672        return Ok(None);
673    };
674
675    let kube_duration =
676        GatewayDuration::from_str(d).map_err(|e| Error::new(format!("invalid duration: {e}")))?;
677
678    let secs = kube_duration.as_secs();
679    let nanos = kube_duration.subsec_nanos();
680    Ok(Some(Duration::new(secs, nanos)))
681}
682
683fn serialize_duration(d: Duration) -> Result<String, Error> {
684    use gateway_api::duration::Duration as GatewayDuration;
685
686    let kube_duration = GatewayDuration::try_from(std::time::Duration::from(d)).map_err(|e| {
687        Error::new(format!(
688            "failed to convert a duration to a Gateway duration: {e}"
689        ))
690    })?;
691
692    Ok(kube_duration.to_string())
693}
694
695#[cfg(test)]
696mod test {
697    use serde_json::json;
698
699    use crate::Hostname;
700
701    use super::*;
702    use std::collections::BTreeMap;
703
704    #[test]
705    fn test_from_gateway_simple_httproute() {
706        assert_from_gateway(
707            json!(
708                {
709                    "apiVersion": "gateway.networking.k8s.io/v1",
710                    "kind": "HTTPRoute",
711                    "metadata": {
712                      "name": "example-route"
713                    },
714                    "spec": {
715                      "parentRefs": [
716                        {"name": "foo-svc", "namespace": "prod", "group": "", "kind": "Service"},
717                      ],
718                      "rules": [
719                        {
720                          "backendRefs": [
721                            {"name": "foo-svc", "namespace": "prod", "port": 8080},
722                          ]
723                        }
724                      ]
725                    }
726                  }
727            ),
728            Route {
729                id: Name::from_static("example-route"),
730                hostnames: vec![Hostname::from_static("foo-svc.prod.svc.cluster.local").into()],
731                ports: vec![],
732                tags: Default::default(),
733                rules: vec![RouteRule {
734                    backends: vec![BackendRef {
735                        weight: 1,
736                        service: Service::kube("prod", "foo-svc").unwrap(),
737                        port: Some(8080),
738                    }],
739                    ..Default::default()
740                }],
741            },
742        );
743    }
744
745    #[test]
746    fn test_from_gateway_sort_rules() {
747        assert_from_gateway(
748            json!(
749                {
750                    "apiVersion": "gateway.networking.k8s.io/v1",
751                    "kind": "HTTPRoute",
752                    "metadata": {
753                      "name": "example-route"
754                    },
755                    "spec": {
756                      "parentRefs": [
757                        {"name": "foo-svc", "namespace": "prod", "group": "", "kind": "Service"},
758                      ],
759                      "rules": [
760                        {
761                          "backendRefs": [
762                            {"name": "foo-svc", "namespace": "prod", "port": 8080},
763                          ]
764                        },
765                        {
766                          "matches": [
767                            {
768                                "path": {
769                                    "type": "PathPrefix",
770                                    "value": "/path5"
771                                }
772                            }
773                          ],
774                          "backendRefs": [
775                            {"name": "bar-svc", "namespace": "prod", "port": 8080},
776                          ]
777                        }
778                      ]
779                    }
780                  }
781            ),
782            Route {
783                id: Name::from_static("example-route"),
784                hostnames: vec![Hostname::from_static("foo-svc.prod.svc.cluster.local").into()],
785                ports: vec![],
786                tags: Default::default(),
787                rules: vec![
788                    RouteRule {
789                        matches: vec![RouteMatch {
790                            path: Some(PathMatch::Prefix {
791                                value: "/path5".to_string(),
792                            }),
793                            ..Default::default()
794                        }],
795                        backends: vec![BackendRef {
796                            weight: 1,
797                            service: Service::kube("prod", "bar-svc").unwrap(),
798                            port: Some(8080),
799                        }],
800                        ..Default::default()
801                    },
802                    RouteRule {
803                        backends: vec![BackendRef {
804                            weight: 1,
805                            service: Service::kube("prod", "foo-svc").unwrap(),
806                            port: Some(8080),
807                        }],
808                        ..Default::default()
809                    },
810                ],
811            },
812        );
813    }
814
815    #[test]
816    fn test_from_gateway_full_httproute() {
817        assert_from_gateway(
818            json!(
819                {
820                    "apiVersion": "gateway.networking.k8s.io/v1",
821                    "kind": "HTTPRoute",
822                    "metadata": {
823                      "name": "example-route"
824                    },
825                    "spec": {
826                      "parentRefs": [
827                        {"name": "foo-svc", "namespace": "prod", "group": "", "kind": "Service", "port": 80},
828                        {"name": "foo-svc", "namespace": "prod", "group": "", "kind": "Service", "port": 443},
829                        {"name": "bar-svc", "namespace": "prod", "group": "", "kind": "Service", "port": 80},
830                        {"name": "bar-svc", "namespace": "prod", "group": "", "kind": "Service", "port": 443},
831                        {"name": "*.s3.internal", "group": "junctionlabs.io", "kind": "DNS", "port": 80},
832                        {"name": "*.s3.internal", "group": "junctionlabs.io", "kind": "DNS", "port": 443 }
833                      ],
834                      "rules": [
835                        {
836                          "name": "a-name",
837                          "matches": [
838                            {
839                              "path": {
840                                "type": "PathPrefix",
841                                "value": "/login"
842                              }
843                            }
844                          ],
845                          "retry": {
846                            "attempts": 3,
847                            "backoff": "1m2s3ms"
848                          },
849                          "timeouts": {
850                            "request": "4m5s6ms"
851                          },
852                          "backendRefs": [
853                            {
854                              "name": "foo-svc",
855                              "namespace": "prod",
856                              "port": 8080
857                            }
858                          ]
859                        },
860                        {
861                          "backendRefs": [
862                            {
863                              "name": "bar-svc",
864                              "namespace": "prod",
865                              "port": 8080
866                            }
867                          ]
868                        }
869                      ]
870                    }
871                  }
872            ),
873            Route {
874                id: Name::from_static("example-route"),
875                hostnames: [
876                    "*.s3.internal",
877                    "bar-svc.prod.svc.cluster.local",
878                    "foo-svc.prod.svc.cluster.local",
879                ]
880                .into_iter()
881                .map(|s| HostnameMatch::from_str(s).unwrap())
882                .collect(),
883                ports: vec![80, 443],
884                tags: Default::default(),
885                rules: vec![
886                    RouteRule {
887                        name: Some(Name::from_static("a-name")),
888                        matches: vec![RouteMatch {
889                            path: Some(PathMatch::Prefix {
890                                value: "/login".to_string(),
891                            }),
892                            ..Default::default()
893                        }],
894                        retry: Some(RouteRetry {
895                            codes: vec![],
896                            attempts: Some(3),
897                            backoff: Some(Duration::from_secs_f64(62.003)),
898                        }),
899                        timeouts: Some(RouteTimeouts {
900                            request: Some(Duration::from_secs_f64(245.006)),
901                            backend_request: None,
902                        }),
903                        backends: vec![BackendRef {
904                            weight: 1,
905                            service: Service::kube("prod", "foo-svc").unwrap(),
906                            port: Some(8080),
907                        }],
908                        ..Default::default()
909                    },
910                    RouteRule {
911                        backends: vec![BackendRef {
912                            weight: 1,
913                            service: Service::kube("prod", "bar-svc").unwrap(),
914                            port: Some(8080),
915                        }],
916                        ..Default::default()
917                    },
918                ],
919            },
920        );
921    }
922
923    #[test]
924    fn test_from_gateway_no_namespaces() {
925        assert_from_gateway(
926            json!(
927                {
928                    "apiVersion": "gateway.networking.k8s.io/v1",
929                    "kind": "HTTPRoute",
930                    "metadata": {
931                      "name": "example-route",
932                      "namespace": "prod"
933                    },
934                    "spec": {
935                      "parentRefs": [
936                        {"name": "foo-svc", "group": "", "kind": "Service", "port": 80},
937                      ],
938                      "rules": [
939                        {
940                          "backendRefs": [
941                            {
942                              "name": "bar-svc",
943                              "port": 8080
944                            }
945                          ]
946                        }
947                      ]
948                    }
949                  }
950            ),
951            Route {
952                id: Name::from_static("example-route"),
953                hostnames: ["foo-svc.prod.svc.cluster.local"]
954                    .into_iter()
955                    .map(|s| HostnameMatch::from_str(s).unwrap())
956                    .collect(),
957                ports: vec![80],
958                tags: Default::default(),
959                rules: vec![RouteRule {
960                    backends: vec![BackendRef {
961                        weight: 1,
962                        service: Service::kube("prod", "bar-svc").unwrap(),
963                        port: Some(8080),
964                    }],
965                    ..Default::default()
966                }],
967            },
968        );
969    }
970
971    #[track_caller]
972    fn assert_from_gateway(gateway_spec: serde_json::Value, expected: Route) {
973        let gateway_route: gateway_http::HTTPRoute = serde_json::from_value(gateway_spec).unwrap();
974        assert_eq!(
975            Route::from_gateway_httproute(&gateway_route).unwrap(),
976            expected,
977        );
978    }
979
980    #[test]
981    fn test_from_gateway_invalid_parent_refs() {
982        assert_from_gateway_err(json!(
983            {
984                "apiVersion": "gateway.networking.k8s.io/v1",
985                "kind": "HTTPRoute",
986                "metadata": {
987                  "name": "example-route"
988                },
989                "spec": {
990                  "parentRefs": [
991                    // the two services here have different port values
992                    {"name": "foo-svc", "namespace": "prod", "group": "", "kind": "Service", "port": 80},
993                    {"name": "bar-svc", "namespace": "prod", "group": "", "kind": "Service", "port": 443},
994                  ],
995                  "rules": [
996                    {
997                      "backendRefs": [
998                        {"name": "foo-svc", "namespace": "prod", "port": 8080},
999                      ]
1000                    }
1001                  ]
1002                }
1003              }
1004        ));
1005
1006        assert_from_gateway_err(json!(
1007            {
1008                "apiVersion": "gateway.networking.k8s.io/v1",
1009                "kind": "HTTPRoute",
1010                "metadata": {
1011                  "name": "example-route"
1012                },
1013                "spec": {
1014                  "parentRefs": [
1015                    // one specifies a port, the other does not. this is illegal by the gateway spec
1016                    {"name": "foo-svc", "namespace": "prod", "group": "", "kind": "Service"},
1017                    {"name": "bar-svc", "namespace": "prod", "group": "", "kind": "Service", "port": 443},
1018                  ],
1019                  "rules": [
1020                    {
1021                      "backendRefs": [
1022                        {"name": "foo-svc", "namespace": "prod", "port": 8080},
1023                      ]
1024                    }
1025                  ]
1026                }
1027              }
1028        ));
1029    }
1030
1031    #[track_caller]
1032    fn assert_from_gateway_err(gateway_spec: serde_json::Value) {
1033        let gateway_route: gateway_http::HTTPRoute = serde_json::from_value(gateway_spec).unwrap();
1034        assert!(
1035            Route::from_gateway_httproute(&gateway_route).is_err(),
1036            "expected an invalid route, but deserialized ok",
1037        )
1038    }
1039
1040    #[test]
1041    fn test_roundtrip_simple_route() {
1042        assert_roundtrip(Route {
1043            id: Name::from_static("simple-route"),
1044            hostnames: vec!["foo.bar".parse().unwrap()],
1045            ports: vec![],
1046            tags: Default::default(),
1047            rules: vec![RouteRule {
1048                backends: vec![BackendRef {
1049                    weight: 1,
1050                    service: Service::kube("default", "foo-svc").unwrap(),
1051                    port: Some(8080),
1052                }],
1053                ..Default::default()
1054            }],
1055        });
1056    }
1057
1058    #[test]
1059    fn test_roundtrip_full_route() {
1060        assert_roundtrip(Route {
1061            id: Name::from_static("full-route"),
1062            hostnames: vec!["*.foo.bar".parse().unwrap(), "foo.bar".parse().unwrap()],
1063            ports: vec![80, 8080],
1064            tags: BTreeMap::from_iter([
1065                ("foo".to_string(), "bar".to_string()),
1066                ("one".to_string(), "seven".to_string()),
1067            ]),
1068            rules: vec![RouteRule {
1069                matches: vec![RouteMatch {
1070                    path: Some(PathMatch::Prefix {
1071                        value: "/login".to_string(),
1072                    }),
1073                    ..Default::default()
1074                }],
1075                retry: Some(RouteRetry {
1076                    codes: vec![500, 503],
1077                    attempts: Some(3),
1078                    backoff: Some(Duration::from_secs(2)),
1079                }),
1080                timeouts: Some(RouteTimeouts {
1081                    request: Some(Duration::from_secs(2)),
1082                    backend_request: Some(Duration::from_secs(1)),
1083                }),
1084                backends: vec![BackendRef {
1085                    weight: 1,
1086                    service: Service::kube("default", "foo-svc").unwrap(),
1087                    port: Some(8080),
1088                }],
1089                ..Default::default()
1090            }],
1091        });
1092    }
1093
1094    #[track_caller]
1095    fn assert_roundtrip(route: Route) {
1096        assert_eq!(
1097            route,
1098            Route::from_gateway_httproute(
1099                &route.to_gateway_httproute("a-namespace-test").unwrap(),
1100            )
1101            .unwrap(),
1102        );
1103    }
1104}