Skip to content

Commit 38df8f7

Browse files
ericmlujancursoragentclalancette
authored
Add live visualization credentials provider (#836)
### Changelog - None ### Docs - None ### Description Introduce a mechanism for fetching and refreshing live visualization credentials from the Foxglove API using a device token. This PR introduces an internal `api_client` mod that allows for communication with the Foxglove API in service of: 1. Retrieving device metadata from a token using the `/internal/platform/v1/device-info` endpoint 2. Retrieving device-specific RTC credentials from the `/internal/platform/v1/devices/:device_id/remote-sessions` endpoint. This mod is gated by the `agent` flag, hidden from documentation, and comments have been added to warn users that this is not intended to be used directly. Not all Foxglove API endpoints are implemented in this mod, just the bare essentials required to authorize a device and retrieve credentials. The only supported authentication method is a device token, as this client is currently only intended for use by the live visualization feature. In addition to tokio tests that mock the above endpoints, I also made a local test executable that was able to successfully retrieve credentials from the production Foxglove API. Note that this is only implemented in Rust until an initial Rust implementation is complete and the interfaces settle down. Will then port data types and interfaces to Python and C++ if needed. <!-- Link relevant GitHub or Linear issues. Use `Fixes: https://github.com/<orgname>/<reponame>/issues/123` for GitHub issues or `Fixes: [ABC-123](https://linear.app/foxglove/issue/ABC-123)` for Linear issues. Put each issue on a separate line. --> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Chris Lalancette <chris.lalancette@foxglove.dev>
1 parent a813c15 commit 38df8f7

File tree

10 files changed

+1354
-13
lines changed

10 files changed

+1354
-13
lines changed

Cargo.lock

Lines changed: 712 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ prost-types = "0.14"
3232
serde = { version = "1.0", features = ["derive"] }
3333
serde_with = { version = "3.14.0", features = ["macros", "base64"] }
3434
thiserror = "2.0"
35-
tokio = { version = "1.47", features = ["macros", "rt-multi-thread", "signal", "time"] }
35+
tokio = { version = "1.47", features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
3636
tokio-tungstenite = "0.28"
3737
tokio-util = { version = "0.7", features = ["rt"] }
3838
tracing = { version = "0.1", features = ["log"] }

rust/foxglove/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ license.workspace = true
1111
default = ["derive", "live_visualization", "lz4", "zstd"]
1212
chrono = ["dep:chrono"]
1313
derive = ["dep:foxglove_derive"]
14-
agent = ["live_visualization"]
14+
agent = ["live_visualization", "tls", "dep:reqwest", "dep:percent-encoding"]
1515
live_visualization = [
1616
"dep:base64",
1717
"dep:flume",
@@ -88,10 +88,13 @@ image = { version = "0.25.9", default-features = false, optional = true }
8888
yuv = { version = "0.8.9", optional = true }
8989
regex = { version = "1", optional = true }
9090
cdr = { version = "0.2.4", optional = true }
91+
reqwest = { version = "0.13.1", features = ["json", "rustls"], optional = true }
92+
percent-encoding = { version = "2.3.2", optional = true }
9193

9294
[dev-dependencies]
9395
assert_matches = "1.5.0"
9496
insta.workspace = true
97+
axum = "0.8.8"
9598
hexdump = "0.1.2"
9699
maplit = "1.0.2"
97100
serde_cbor = "0.11.2"
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
use std::fmt::Display;
2+
use std::time::Duration;
3+
4+
use percent_encoding::AsciiSet;
5+
use reqwest::header::{HeaderMap, AUTHORIZATION, USER_AGENT};
6+
use reqwest::{Method, StatusCode};
7+
use thiserror::Error;
8+
9+
use crate::library_version::{get_sdk_language, get_sdk_version};
10+
11+
use super::types::{DeviceResponse, ErrorResponse, RtcCredentials};
12+
13+
const DEFAULT_API_URL: &str = "https://api.foxglove.dev";
14+
15+
const PATH_ENCODING: AsciiSet = percent_encoding::NON_ALPHANUMERIC
16+
.remove(b'-')
17+
.remove(b'.')
18+
.remove(b'_')
19+
.remove(b'~');
20+
21+
fn encode_uri_component(component: &str) -> impl Display + '_ {
22+
percent_encoding::percent_encode(component.as_bytes(), &PATH_ENCODING)
23+
}
24+
25+
#[derive(Clone)]
26+
pub(crate) struct DeviceToken(String);
27+
28+
impl DeviceToken {
29+
pub fn new(token: impl Into<String>) -> Self {
30+
Self(token.into())
31+
}
32+
33+
fn to_header(&self) -> String {
34+
format!("DeviceToken {}", self.0)
35+
}
36+
}
37+
38+
#[derive(Error, Debug)]
39+
#[non_exhaustive]
40+
pub(crate) enum RequestError {
41+
#[error("failed to send request: {0}")]
42+
SendRequest(#[source] reqwest::Error),
43+
44+
#[error("failed to load response bytes: {0}")]
45+
LoadResponseBytes(#[source] reqwest::Error),
46+
47+
#[error("received error response {status}: {error:?}")]
48+
ErrorResponse {
49+
status: StatusCode,
50+
error: ErrorResponse,
51+
headers: Box<HeaderMap>,
52+
},
53+
54+
#[error("received malformed error response {status} with body '{body}'")]
55+
MalformedErrorResponse {
56+
status: StatusCode,
57+
body: String,
58+
headers: Box<HeaderMap>,
59+
},
60+
61+
#[error("failed to parse response: {0}")]
62+
ParseResponse(#[source] serde_json::Error),
63+
}
64+
65+
#[derive(Error, Debug)]
66+
#[non_exhaustive]
67+
pub(crate) enum FoxgloveApiClientError {
68+
#[error(transparent)]
69+
Request(#[from] RequestError),
70+
71+
#[error("failed to build client: {0}")]
72+
BuildClient(#[from] reqwest::Error),
73+
}
74+
75+
impl FoxgloveApiClientError {
76+
pub fn status_code(&self) -> Option<StatusCode> {
77+
match self {
78+
Self::Request(
79+
RequestError::MalformedErrorResponse { status, .. }
80+
| RequestError::ErrorResponse { status, .. },
81+
) => Some(*status),
82+
_ => None,
83+
}
84+
}
85+
}
86+
87+
#[must_use]
88+
pub(crate) struct RequestBuilder(reqwest::RequestBuilder);
89+
90+
impl RequestBuilder {
91+
fn new(client: &reqwest::Client, method: Method, url: &str, user_agent: &str) -> Self {
92+
Self(client.request(method, url).header(USER_AGENT, user_agent))
93+
}
94+
95+
pub fn device_token(mut self, token: &DeviceToken) -> Self {
96+
self.0 = self.0.header(AUTHORIZATION, token.to_header());
97+
self
98+
}
99+
100+
pub async fn send(self) -> Result<reqwest::Response, RequestError> {
101+
let response = self.0.send().await.map_err(RequestError::SendRequest)?;
102+
103+
let status = response.status();
104+
if status.is_client_error() || status.is_server_error() {
105+
let headers = Box::new(response.headers().clone());
106+
let body = response
107+
.bytes()
108+
.await
109+
.map_err(RequestError::LoadResponseBytes)?;
110+
match serde_json::from_slice::<ErrorResponse>(&body) {
111+
Ok(error) => {
112+
return Err(RequestError::ErrorResponse {
113+
status,
114+
error,
115+
headers,
116+
});
117+
}
118+
Err(_) => {
119+
let body = String::from_utf8_lossy(&body).to_string();
120+
return Err(RequestError::MalformedErrorResponse {
121+
status,
122+
body,
123+
headers,
124+
});
125+
}
126+
}
127+
}
128+
129+
Ok(response)
130+
}
131+
}
132+
133+
pub(crate) fn default_user_agent() -> String {
134+
format!(
135+
"foxglove-sdk/{} ({})",
136+
get_sdk_language(),
137+
get_sdk_version()
138+
)
139+
}
140+
141+
/// Internal API client for communicating with the Foxglove platform.
142+
///
143+
/// This client is intended for internal use only to support the live visualization feature
144+
/// and is subject to breaking changes at any time. Do not depend on the stability of this type.
145+
#[derive(Clone)]
146+
pub(crate) struct FoxgloveApiClient<A: Clone> {
147+
http: reqwest::Client,
148+
auth: A,
149+
base_url: String,
150+
user_agent: String,
151+
}
152+
153+
impl<A: Clone> FoxgloveApiClient<A> {
154+
fn new(
155+
base_url: impl Into<String>,
156+
auth: A,
157+
user_agent: impl Into<String>,
158+
timeout_duration: Duration,
159+
) -> Result<Self, FoxgloveApiClientError> {
160+
Ok(Self {
161+
http: reqwest::ClientBuilder::new()
162+
.timeout(timeout_duration)
163+
.build()?,
164+
auth,
165+
base_url: base_url.into(),
166+
user_agent: user_agent.into(),
167+
})
168+
}
169+
170+
fn request(&self, method: Method, path: &str) -> RequestBuilder {
171+
let url = format!(
172+
"{}/{}",
173+
self.base_url.trim_end_matches('/'),
174+
path.trim_start_matches('/')
175+
);
176+
RequestBuilder::new(&self.http, method, &url, &self.user_agent)
177+
}
178+
179+
pub fn get(&self, endpoint: &str) -> RequestBuilder {
180+
self.request(Method::GET, endpoint)
181+
}
182+
183+
pub fn post(&self, endpoint: &str) -> RequestBuilder {
184+
self.request(Method::POST, endpoint)
185+
}
186+
}
187+
188+
impl FoxgloveApiClient<DeviceToken> {
189+
/// Fetches device information from the Foxglove platform.
190+
///
191+
/// This endpoint is not intended for direct usage. Access may be blocked if suspicious
192+
/// activity is detected.
193+
pub async fn fetch_device_info(&self) -> Result<DeviceResponse, FoxgloveApiClientError> {
194+
let response = self
195+
.get("/internal/platform/v1/device-info")
196+
.device_token(&self.auth)
197+
.send()
198+
.await?;
199+
200+
let bytes = response
201+
.bytes()
202+
.await
203+
.map_err(super::client::RequestError::LoadResponseBytes)?;
204+
205+
serde_json::from_slice(&bytes).map_err(|e| {
206+
FoxgloveApiClientError::Request(super::client::RequestError::ParseResponse(e))
207+
})
208+
}
209+
210+
/// Authorizes a remote visualization session for the given device.
211+
///
212+
/// This endpoint is not intended for direct usage. Access may be blocked if suspicious
213+
/// activity is detected.
214+
pub async fn authorize_remote_viz(
215+
&self,
216+
device_id: &str,
217+
) -> Result<RtcCredentials, FoxgloveApiClientError> {
218+
let device_id = encode_uri_component(device_id);
219+
let response = self
220+
.post(&format!(
221+
"/internal/platform/v1/devices/{device_id}/remote-sessions"
222+
))
223+
.device_token(&self.auth)
224+
.send()
225+
.await?;
226+
227+
let bytes = response
228+
.bytes()
229+
.await
230+
.map_err(super::client::RequestError::LoadResponseBytes)?;
231+
232+
serde_json::from_slice(&bytes).map_err(|e| {
233+
FoxgloveApiClientError::Request(super::client::RequestError::ParseResponse(e))
234+
})
235+
}
236+
}
237+
238+
pub(crate) struct FoxgloveApiClientBuilder<A> {
239+
auth: A,
240+
base_url: String,
241+
user_agent: String,
242+
timeout_duration: Duration,
243+
}
244+
245+
impl<A> FoxgloveApiClientBuilder<A> {
246+
pub fn new(auth: A) -> Self {
247+
Self {
248+
auth,
249+
base_url: DEFAULT_API_URL.to_string(),
250+
user_agent: default_user_agent(),
251+
timeout_duration: Duration::from_secs(30),
252+
}
253+
}
254+
255+
pub fn base_url(mut self, url: impl Into<String>) -> Self {
256+
self.base_url = url.into();
257+
self
258+
}
259+
260+
pub fn user_agent(mut self, agent: impl Into<String>) -> Self {
261+
self.user_agent = agent.into();
262+
self
263+
}
264+
265+
pub fn timeout(mut self, duration: Duration) -> Self {
266+
self.timeout_duration = duration;
267+
self
268+
}
269+
270+
pub fn build(self) -> Result<FoxgloveApiClient<A>, FoxgloveApiClientError>
271+
where
272+
A: Clone,
273+
{
274+
FoxgloveApiClient::new(
275+
self.base_url,
276+
self.auth,
277+
self.user_agent,
278+
self.timeout_duration,
279+
)
280+
}
281+
}
282+
283+
#[cfg(test)]
284+
mod tests {
285+
use crate::api_client::test_utils::{
286+
create_test_api_client, create_test_server, TEST_DEVICE_ID, TEST_DEVICE_TOKEN,
287+
TEST_PROJECT_ID,
288+
};
289+
290+
use super::DeviceToken;
291+
292+
#[tokio::test]
293+
async fn fetch_device_info_success() {
294+
let server = create_test_server().await;
295+
let client = create_test_api_client(server.url(), DeviceToken::new(TEST_DEVICE_TOKEN));
296+
let result = client
297+
.fetch_device_info()
298+
.await
299+
.expect("could not authorize device info");
300+
301+
assert_eq!(result.id, TEST_DEVICE_ID);
302+
assert_eq!(result.name, "Test Device");
303+
assert_eq!(result.project_id, TEST_PROJECT_ID);
304+
assert_eq!(result.retain_recordings_seconds, Some(3600));
305+
}
306+
307+
#[tokio::test]
308+
async fn fetch_device_info_unauthorized() {
309+
let server = create_test_server().await;
310+
let client =
311+
create_test_api_client(server.url(), DeviceToken::new("some-bad-device-token"));
312+
let result = client.fetch_device_info().await;
313+
314+
assert!(result.is_err());
315+
}
316+
317+
#[tokio::test]
318+
async fn authorize_remote_viz_success() {
319+
let server = create_test_server().await;
320+
let client = create_test_api_client(server.url(), DeviceToken::new(TEST_DEVICE_TOKEN));
321+
322+
let result = client
323+
.authorize_remote_viz(TEST_DEVICE_ID)
324+
.await
325+
.expect("could not authorize remote viz");
326+
assert_eq!(result.token, "rtc-token-abc123");
327+
assert_eq!(result.url, "wss://rtc.foxglove.dev");
328+
}
329+
330+
#[tokio::test]
331+
async fn authorize_remote_viz_unauthorized() {
332+
let server = create_test_server().await;
333+
let client =
334+
create_test_api_client(server.url(), DeviceToken::new("some-bad-device-token"));
335+
let result = client.authorize_remote_viz(TEST_DEVICE_ID).await;
336+
assert!(result.is_err());
337+
}
338+
}

0 commit comments

Comments
 (0)