Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions generators/cli/changes/unreleased/add-fern-platform-headers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# yaml-language-server: $schema=../../../../fern-changes-yml.schema.json

- summary: |
Generated CLIs now send Fern platform-identification headers on every HTTP
request and WebSocket handshake: `X-Fern-SDK-Name` (e.g. `elevenlabs-cli`),
`X-Fern-SDK-Version` (the CLI's package version), `X-Fern-Language: Rust`,
and `X-Fern-CLI-Command` (the invoked command path, e.g.
`text-to-speech.convert`), matching the convention used by Fern SDK
generators.
type: feat
14 changes: 11 additions & 3 deletions generators/cli/sdk/src/asyncapi/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,17 @@ impl Binding for AsyncApiBinding {
);
let base_url_override = composed.as_deref();

let http_config = prepared.http_config.clone().with_user_agent_suffix_override(
crate::cli_args::resolve_user_agent_suffix_override(root_matches),
);
// The channel's full command path == sdk_group_name ++ [leaf],
// mirroring the match in `resolve_channel`.
let mut command_path = channel.sdk_group_name.clone();
command_path.push(commands::leaf_command_name(channel_name, channel));
let http_config = prepared
.http_config
.clone()
.with_user_agent_suffix_override(
crate::cli_args::resolve_user_agent_suffix_override(root_matches),
)
.with_cli_command(Some(command_path.join(".")));

// Resolve binding-level CLI args (e.g. `--voice`, `--audio-out`)
// ONCE per dispatch, then pick the init payload and
Expand Down
10 changes: 7 additions & 3 deletions generators/cli/sdk/src/graphql/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,13 @@ impl Binding for GraphqlBinding {
crate::cli_args::resolve_base_url_override(root_matches, &self.inner.name)?;
let base_url_override = base_url_override_owned.as_deref();

let http_config = prepared.http_config.clone().with_user_agent_suffix_override(
crate::cli_args::resolve_user_agent_suffix_override(root_matches),
);
let http_config = prepared
.http_config
.clone()
.with_user_agent_suffix_override(
crate::cli_args::resolve_user_agent_suffix_override(root_matches),
)
.with_cli_command(Some(_op_path.join(".")));

// When --page-all is active on a TTY without --no-pager,
// let the executor write directly to the pager (capture_output
Expand Down
120 changes: 117 additions & 3 deletions generators/cli/sdk/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use std::collections::HashSet;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT};

use crate::error::CliError;

Expand Down Expand Up @@ -74,6 +74,10 @@ pub struct HttpConfig {
/// `<NAME>_USER_AGENT_SUFFIX` env var when set. `None` means fall back
/// to the env var (or no suffix).
user_agent_suffix_override: Option<Arc<str>>,
/// Dot-separated invoked command path (e.g. `text-to-speech.convert`),
/// sent as the `X-Fern-CLI-Command` header. `None` when the dispatch
/// path doesn't know the invoked command (e.g. programmatic consumers).
cli_command: Option<Arc<str>>,
}

/// Transport-neutral view of the resolved HTTP/TLS configuration.
Expand Down Expand Up @@ -139,6 +143,7 @@ impl HttpConfig {
extra_root_certs: Vec::new(),
extra_root_certs_pem: Vec::new(),
user_agent_suffix_override: None,
cli_command: None,
})
}

Expand Down Expand Up @@ -183,6 +188,36 @@ impl HttpConfig {
self
}

/// Set the invoked command path (dot-separated, e.g.
/// `text-to-speech.convert`), sent as the `X-Fern-CLI-Command` header so
/// a backend can attribute a request to the exact command that made it.
/// A blank or header-invalid value clears the header rather than failing
/// client construction.
pub fn with_cli_command(mut self, command: Option<String>) -> Self {
self.cli_command = command
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && HeaderValue::from_str(s).is_ok())
.map(Arc::from);
self
}

/// Fern platform-identification headers sent on every request, matching
/// the convention Fern SDK generators use: `X-Fern-SDK-Name`,
/// `X-Fern-SDK-Version`, and `X-Fern-Language`, plus `X-Fern-CLI-Command`
/// when the invoked command path is known (see
/// [`HttpConfig::with_cli_command`]).
pub fn platform_headers(&self) -> Vec<(&'static str, String)> {
let mut headers = vec![
("X-Fern-Language", "Rust".to_string()),
("X-Fern-SDK-Name", Self::user_agent_product(&self.name)),
("X-Fern-SDK-Version", env!("CARGO_PKG_VERSION").to_string()),
];
if let Some(command) = &self.cli_command {
headers.push(("X-Fern-CLI-Command", command.to_string()));
}
headers
}

/// CLI binary name (e.g. `"bigcommerce"`).
pub fn name(&self) -> &str {
&self.name
Expand Down Expand Up @@ -334,12 +369,20 @@ impl HttpConfig {
let prefix = &self.prefix;

let mut builder = reqwest::Client::builder();
let mut headers = HeaderMap::new();
let user_agent = self.user_agent();
if let Ok(header_value) = HeaderValue::from_str(&user_agent) {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, header_value);
builder = builder.default_headers(headers);
}
for (name, value) in self.platform_headers() {
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(&value),
) {
headers.insert(name, value);
}
}
builder = builder.default_headers(headers);

// --- Compile-time trust roots (from CliApp::extra_root_cert) ---
for cert in &self.extra_root_certs {
Expand Down Expand Up @@ -1126,6 +1169,77 @@ mod tests {
);
}

#[test]
fn platform_headers_identify_the_cli() {
let cfg = HttpConfig::new("elevenlabs").unwrap();
let headers = cfg.platform_headers();
assert!(headers.contains(&("X-Fern-Language", "Rust".to_string())));
assert!(headers.contains(&("X-Fern-SDK-Name", "elevenlabs-cli".to_string())));
assert!(headers.contains(&(
"X-Fern-SDK-Version",
env!("CARGO_PKG_VERSION").to_string()
)));
// No command header until one is set.
assert!(!headers.iter().any(|(n, _)| *n == "X-Fern-CLI-Command"));
}

#[test]
fn platform_headers_include_the_invoked_command_when_set() {
let cfg = HttpConfig::new("elevenlabs")
.unwrap()
.with_cli_command(Some("text-to-speech.convert".to_string()));
assert!(cfg.platform_headers().contains(&(
"X-Fern-CLI-Command",
"text-to-speech.convert".to_string()
)));
}

#[test]
fn with_cli_command_ignores_blank_or_invalid_values() {
for bad in ["", " ", "bad\nvalue"] {
let cfg = HttpConfig::new("elevenlabs")
.unwrap()
.with_cli_command(Some(bad.to_string()));
assert!(
!cfg.platform_headers()
.iter()
.any(|(n, _)| *n == "X-Fern-CLI-Command"),
"value {bad:?} must not produce a command header"
);
}
}

#[tokio::test]
#[serial_test::serial]
async fn build_client_sends_platform_headers_on_the_wire() {
let server =
always_respond(wiremock::ResponseTemplate::new(200).set_body_string("{}")).await;
let client = HttpConfig::new("elevenlabs")
.expect("config")
.with_cli_command(Some("text-to-speech.convert".to_string()))
.build_client()
.expect("client");
client.get(server.uri()).send().await.expect("request");

let requests = server.received_requests().await.unwrap_or_default();
let headers = &requests[0].headers;
let header = |name: &str| {
headers
.get(name)
.map(|v| v.to_str().unwrap().to_string())
};
assert_eq!(header("x-fern-sdk-name"), Some("elevenlabs-cli".to_string()));
assert_eq!(
header("x-fern-sdk-version"),
Some(env!("CARGO_PKG_VERSION").to_string()),
);
assert_eq!(header("x-fern-language"), Some("Rust".to_string()));
assert_eq!(
header("x-fern-cli-command"),
Some("text-to-speech.convert".to_string()),
);
}

#[test]
fn with_extra_root_cert_rejects_non_pem() {
let cfg = HttpConfig::new("regtest").unwrap();
Expand Down
10 changes: 7 additions & 3 deletions generators/cli/sdk/src/openapi/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,9 +659,13 @@ impl Binding for OpenApiBinding {
// --user-agent-suffix flag wins; otherwise {NAME}_USER_AGENT_SUFFIX
// env var (resolved inside HttpConfig). Apply the flag override to
// a clone so the client this request builds carries it.
let http_config = prepared.http_config.clone().with_user_agent_suffix_override(
crate::cli_args::resolve_user_agent_suffix_override(root_matches),
);
let http_config = prepared
.http_config
.clone()
.with_user_agent_suffix_override(
crate::cli_args::resolve_user_agent_suffix_override(root_matches),
)
.with_cli_command(Some(_op_path.join(".")));

// Read --output flag for binary response file writing. The literal
// `-` is a stdout sentinel (curl/wget convention) and bypasses
Expand Down
66 changes: 59 additions & 7 deletions generators/cli/sdk/src/websocket/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,13 @@ impl WebSocketClient {
config.auth.apply_to_url_and_headers(&mut url, &mut headers)?;

// Build the handshake request (WS control headers + User-Agent +
// auth headers). See `build_handshake_request`.
let request = build_handshake_request(&url, &headers, &http_config.user_agent())?;
// Fern platform headers + auth headers). See `build_handshake_request`.
let request = build_handshake_request(
&url,
&headers,
&http_config.user_agent(),
&http_config.platform_headers(),
)?;

// Sync the URL on the WsConfig with what we actually connected to,
// so anything downstream that reads it (logging, error messages)
Expand Down Expand Up @@ -678,6 +683,7 @@ fn build_handshake_request(
url: &str,
headers: &[(String, String)],
user_agent: &str,
platform_headers: &[(&'static str, String)],
) -> Result<tokio_tungstenite::tungstenite::handshake::client::Request, CliError> {
let uri: tokio_tungstenite::tungstenite::http::Uri = url
.parse()
Expand All @@ -691,6 +697,18 @@ fn build_handshake_request(
);
}

// Fern platform-identification headers (X-Fern-SDK-Name, X-Fern-SDK-Version,
// X-Fern-Language, X-Fern-CLI-Command). Applied before auth headers, like
// the User-Agent, so an auth-supplied header can still override.
for (name, value) in platform_headers {
if let (Ok(name), Ok(value)) = (
name.parse::<tokio_tungstenite::tungstenite::http::HeaderName>(),
HeaderValue::from_str(value),
) {
request.headers_mut().insert(name, value);
}
}

for (name, value) in headers {
let header_value = HeaderValue::from_str(value).map_err(|e| {
CliError::Validation(format!(
Expand All @@ -715,22 +733,56 @@ mod tests {
#[test]
fn handshake_request_sets_user_agent() {
let request =
build_handshake_request("wss://example.com/socket", &[], "elevenlabs-cli/1.4.0")
build_handshake_request("wss://example.com/socket", &[], "elevenlabs-cli/1.4.0", &[])
.expect("request builds");
assert_eq!(
request.headers().get(USER_AGENT).map(|v| v.to_str().unwrap()),
Some("elevenlabs-cli/1.4.0"),
);
}

#[test]
fn handshake_request_sets_platform_headers() {
let http_config = crate::http::HttpConfig::new("elevenlabs")
.expect("config")
.with_cli_command(Some("conversational-ai.stream".to_string()));
let request = build_handshake_request(
"wss://example.com/socket",
&[],
&http_config.user_agent(),
&http_config.platform_headers(),
)
.expect("request builds");
let header = |name: &str| {
request
.headers()
.get(name)
.map(|v| v.to_str().unwrap().to_string())
};
assert_eq!(header("x-fern-sdk-name"), Some("elevenlabs-cli".to_string()));
assert_eq!(
header("x-fern-sdk-version"),
Some(env!("CARGO_PKG_VERSION").to_string()),
);
assert_eq!(header("x-fern-language"), Some("Rust".to_string()));
assert_eq!(
header("x-fern-cli-command"),
Some("conversational-ai.stream".to_string()),
);
}

#[test]
fn handshake_request_auth_header_can_override_user_agent() {
// An explicit auth-supplied User-Agent wins over the default, since
// auth headers are layered after the CLI identity.
let headers = vec![("user-agent".to_string(), "custom/9.9".to_string())];
let request =
build_handshake_request("wss://example.com/socket", &headers, "elevenlabs-cli/1.4.0")
.expect("request builds");
let request = build_handshake_request(
"wss://example.com/socket",
&headers,
"elevenlabs-cli/1.4.0",
&[],
)
.expect("request builds");
assert_eq!(
request.headers().get(USER_AGENT).map(|v| v.to_str().unwrap()),
Some("custom/9.9"),
Expand All @@ -741,7 +793,7 @@ mod tests {
fn handshake_request_skips_invalid_user_agent() {
// A malformed User-Agent is dropped rather than failing the handshake;
// the other required WS headers are still present.
let request = build_handshake_request("wss://example.com/socket", &[], "bad\nvalue")
let request = build_handshake_request("wss://example.com/socket", &[], "bad\nvalue", &[])
.expect("request builds");
assert!(request.headers().get(USER_AGENT).is_none());
assert!(request
Expand Down
Loading