1use serde::{Deserialize, Serialize};
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Endpoint {
31 pub kind: String,
34 pub url: String,
38 #[serde(default)]
40 pub priority: i32,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub health_hint_unix: Option<i64>,
45}
46
47impl Endpoint {
48 pub const KIND_CLOUDFLARE_QUICK: &'static str = "cloudflare_quick";
50 pub const KIND_CLOUDFLARE_NAMED: &'static str = "cloudflare_named";
52 pub const KIND_IROH: &'static str = "iroh";
54 pub const KIND_TAILSCALE_FUNNEL: &'static str = "tailscale_funnel";
56 pub const KIND_FRP: &'static str = "frp";
58
59 pub const KNOWN_KINDS: &'static [&'static str] = &[
62 Self::KIND_CLOUDFLARE_QUICK,
63 Self::KIND_CLOUDFLARE_NAMED,
64 Self::KIND_IROH,
65 Self::KIND_TAILSCALE_FUNNEL,
66 Self::KIND_FRP,
67 ];
68
69 pub fn is_known_kind(&self) -> bool {
72 Self::KNOWN_KINDS.contains(&self.kind.as_str())
73 }
74
75 pub fn cloudflare_quick(url: impl Into<String>) -> Self {
77 Self {
78 kind: Self::KIND_CLOUDFLARE_QUICK.into(),
79 url: url.into(),
80 priority: 0,
81 health_hint_unix: None,
82 }
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn cloudflare_quick_builder() {
92 let e = Endpoint::cloudflare_quick("https://foo.trycloudflare.com");
93 assert_eq!(e.kind, "cloudflare_quick");
94 assert_eq!(e.url, "https://foo.trycloudflare.com");
95 assert_eq!(e.priority, 0);
96 assert!(e.is_known_kind());
97 }
98
99 #[test]
100 fn unknown_kind_preserved_and_flagged() {
101 let e = Endpoint {
102 kind: "future_transport_v9".into(),
103 url: "future:alien@mars:443".into(),
104 priority: 5,
105 health_hint_unix: None,
106 };
107 assert!(!e.is_known_kind());
108 }
109
110 #[test]
111 fn serde_roundtrip_minimal() {
112 let original = Endpoint::cloudflare_quick("https://x.trycloudflare.com");
113 let json = serde_json::to_string(&original).unwrap();
114 assert!(json.contains(r#""kind":"cloudflare_quick""#));
116 assert!(json.contains(r#""url":"https://x.trycloudflare.com""#));
117 assert!(!json.contains("health_hint_unix"));
118 let back: Endpoint = serde_json::from_str(&json).unwrap();
119 assert_eq!(back, original);
120 }
121
122 #[test]
123 fn serde_roundtrip_with_health_hint() {
124 let original = Endpoint {
125 kind: Endpoint::KIND_IROH.into(),
126 url: "iroh:abc123@relay.aex.dev:443".into(),
127 priority: 1,
128 health_hint_unix: Some(1_700_000_000),
129 };
130 let json = serde_json::to_string(&original).unwrap();
131 assert!(json.contains(r#""health_hint_unix":1700000000"#));
132 let back: Endpoint = serde_json::from_str(&json).unwrap();
133 assert_eq!(back, original);
134 }
135
136 #[test]
137 fn deserialize_preserves_unknown_kind() {
138 let json = r#"{"kind":"unknown_transport","url":"x://y","priority":9}"#;
139 let e: Endpoint = serde_json::from_str(json).unwrap();
140 assert_eq!(e.kind, "unknown_transport");
141 assert!(!e.is_known_kind());
142 }
143
144 #[test]
145 fn priority_defaults_to_zero_when_missing() {
146 let json = r#"{"kind":"cloudflare_quick","url":"https://x.trycloudflare.com"}"#;
147 let e: Endpoint = serde_json::from_str(json).unwrap();
148 assert_eq!(e.priority, 0);
149 assert_eq!(e.health_hint_unix, None);
150 }
151
152 #[test]
153 fn known_kinds_covers_sprint_2_transports() {
154 for k in [
155 Endpoint::KIND_CLOUDFLARE_QUICK,
156 Endpoint::KIND_CLOUDFLARE_NAMED,
157 Endpoint::KIND_IROH,
158 Endpoint::KIND_TAILSCALE_FUNNEL,
159 Endpoint::KIND_FRP,
160 ] {
161 assert!(Endpoint::KNOWN_KINDS.contains(&k), "kind {k} missing");
162 }
163 }
164}