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 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 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 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 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 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 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 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 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 {"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 {"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}