phoenix_channel_client/client/
endpoint.rs1use std::rc::Rc;
2
3use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD};
4use futures::future::LocalBoxFuture;
5use phoenix_channel_runtime::V2_PROTOCOL_VERSION;
6use thiserror::Error;
7use url::Url;
8
9use super::ConnectContext;
10
11const AUTH_TOKEN_PREFIX: &str = "base64url.bearer.phx.";
12
13#[derive(Clone, Default, Eq, PartialEq)]
15pub struct ConnectionConfig {
16 pub params: Vec<(String, String)>,
18 pub auth_token: Option<String>,
20}
21
22impl std::fmt::Debug for ConnectionConfig {
23 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 formatter
25 .debug_struct("ConnectionConfig")
26 .field("params", &self.params)
27 .field("auth_token", &self.auth_token.as_ref().map(|_| "redacted"))
28 .finish()
29 }
30}
31
32impl ConnectionConfig {
33 pub fn param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
35 self.params.push((name.into(), value.into()));
36 self
37 }
38
39 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
41 self.auth_token = Some(token.into());
42 self
43 }
44}
45
46pub type ConnectionConfigLoader =
48 Rc<dyn Fn(ConnectContext) -> LocalBoxFuture<'static, Result<ConnectionConfig, String>>>;
49
50pub fn static_connection_config(config: ConnectionConfig) -> ConnectionConfigLoader {
52 Rc::new(move |_| {
53 let config = config.clone();
54 Box::pin(async move { Ok(config) })
55 })
56}
57
58#[derive(Clone)]
60pub struct Endpoint {
61 url: Url,
62 config_loader: ConnectionConfigLoader,
63}
64
65impl Endpoint {
66 pub fn new(url: impl AsRef<str>) -> Result<Self, EndpointError> {
71 let mut url = Url::parse(url.as_ref())?;
72 match url.scheme() {
73 "ws" | "wss" => {}
74 scheme => return Err(EndpointError::UnsupportedScheme(scheme.to_owned())),
75 }
76 if !url.path().trim_end_matches('/').ends_with("/websocket") {
77 let path = format!("{}/websocket", url.path().trim_end_matches('/'));
78 url.set_path(&path);
79 }
80 Ok(Self {
81 url,
82 config_loader: static_connection_config(ConnectionConfig::default()),
83 })
84 }
85
86 pub fn connection_config(mut self, config: ConnectionConfig) -> Self {
88 self.config_loader = static_connection_config(config);
89 self
90 }
91
92 pub fn connection_config_loader(mut self, loader: ConnectionConfigLoader) -> Self {
94 self.config_loader = loader;
95 self
96 }
97
98 pub async fn resolve(
100 &self,
101 context: ConnectContext,
102 ) -> Result<ResolvedEndpoint, EndpointError> {
103 let config = (self.config_loader)(context)
104 .await
105 .map_err(EndpointError::Config)?;
106 let mut url = self.url.clone();
107 let existing = url
108 .query_pairs()
109 .filter(|(name, _)| name != "vsn")
110 .map(|(name, value)| (name.into_owned(), value.into_owned()))
111 .collect::<Vec<_>>();
112 url.set_query(None);
113 {
114 let mut query = url.query_pairs_mut();
115 for (name, value) in existing.into_iter().chain(config.params) {
116 query.append_pair(&name, &value);
117 }
118 query.append_pair("vsn", V2_PROTOCOL_VERSION);
119 }
120
121 let protocols = config.auth_token.map_or_else(Vec::new, |token| {
122 vec![
123 "phoenix".to_owned(),
124 format!("{AUTH_TOKEN_PREFIX}{}", STANDARD_NO_PAD.encode(token)),
125 ]
126 });
127 Ok(ResolvedEndpoint {
128 url: url.into(),
129 protocols,
130 })
131 }
132}
133
134#[derive(Clone, Eq, PartialEq)]
136pub struct ResolvedEndpoint {
137 pub url: String,
139 pub protocols: Vec<String>,
141}
142
143impl std::fmt::Debug for ResolvedEndpoint {
144 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 let protocols = self
146 .protocols
147 .iter()
148 .map(|protocol| {
149 if protocol.starts_with(AUTH_TOKEN_PREFIX) {
150 "redacted"
151 } else {
152 protocol.as_str()
153 }
154 })
155 .collect::<Vec<_>>();
156 formatter
157 .debug_struct("ResolvedEndpoint")
158 .field("url", &self.url)
159 .field("protocols", &protocols)
160 .finish()
161 }
162}
163
164#[derive(Clone, Debug, Error, Eq, PartialEq)]
166pub enum EndpointError {
167 #[error("invalid endpoint URL: {0}")]
169 InvalidUrl(String),
170 #[error("unsupported endpoint URL scheme: {0}")]
172 UnsupportedScheme(String),
173 #[error("connection configuration failed: {0}")]
175 Config(String),
176}
177
178impl From<url::ParseError> for EndpointError {
179 fn from(error: url::ParseError) -> Self {
180 Self::InvalidUrl(error.to_string())
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn resolves_the_websocket_path_version_params_and_auth_protocol() {
190 futures::executor::block_on(async {
191 let endpoint = Endpoint::new("wss://example.test/socket?existing=yes")
192 .unwrap()
193 .connection_config(
194 ConnectionConfig::default()
195 .param("user_id", "7")
196 .auth_token("1234"),
197 );
198
199 let resolved = endpoint
200 .resolve(ConnectContext { attempt: 0 })
201 .await
202 .unwrap();
203
204 assert_eq!(
205 resolved.url,
206 "wss://example.test/socket/websocket?existing=yes&user_id=7&vsn=2.0.0"
207 );
208 assert_eq!(
209 resolved.protocols,
210 ["phoenix", "base64url.bearer.phx.MTIzNA"]
211 );
212 });
213 }
214
215 #[test]
216 fn debug_output_redacts_authentication_secrets() {
217 let config = ConnectionConfig::default().auth_token("secret-token");
218 let debug = format!("{config:?}");
219 assert!(!debug.contains("secret-token"));
220
221 let endpoint = futures::executor::block_on(async {
222 Endpoint::new("wss://example.test/socket")
223 .unwrap()
224 .connection_config(config)
225 .resolve(ConnectContext { attempt: 0 })
226 .await
227 .unwrap()
228 });
229 let debug = format!("{endpoint:?}");
230 assert!(!debug.contains("c2VjcmV0LXRva2Vu"));
231 }
232
233 #[test]
234 fn reloads_connection_configuration_for_every_attempt() {
235 futures::executor::block_on(async {
236 let endpoint = Endpoint::new("ws://example.test/socket/websocket?vsn=1.0.0")
237 .unwrap()
238 .connection_config_loader(Rc::new(|context| {
239 Box::pin(async move {
240 Ok(ConnectionConfig::default()
241 .param("attempt", context.attempt.to_string()))
242 })
243 }));
244
245 assert_eq!(
246 endpoint
247 .resolve(ConnectContext { attempt: 3 })
248 .await
249 .unwrap()
250 .url,
251 "ws://example.test/socket/websocket?attempt=3&vsn=2.0.0"
252 );
253 });
254 }
255}