Skip to main content

walletkit_core/
user_agent.rs

1//! User agent for HTTP requests.
2
3const WORLD_APP_USER_AGENT_PRODUCT: &str = "WorldApp";
4const WORLD_ID_APP_USER_AGENT_PRODUCT: &str = "WorldID";
5const WORLD_ID_ANDROID_CLIENT_NAME: &str = "android-id";
6const WORLD_ID_IOS_CLIENT_NAME: &str = "ios-id";
7
8/// Represents a User-Agent string.
9#[derive(Debug, Clone, uniffi::Object)]
10pub struct UserAgent(pub String);
11
12/// Converts the [`UserAgent`] to a [`String`].
13impl std::fmt::Display for UserAgent {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        write!(f, "{}", self.0)?;
16        Ok(())
17    }
18}
19
20#[uniffi::export]
21impl UserAgent {
22    /// Returns the header value for FFI consumers.
23    #[must_use]
24    pub fn header_value(&self) -> String {
25        self.0.clone()
26    }
27}
28
29/// Builds the [`UserAgent`] string sent as the HTTP `User-Agent` header.
30///
31/// Starts empty; call [`Self::with_segment`] for arbitrary `name/version` tokens and
32/// [`Self::with_walletkit_segment`] for the library token (in whatever order fits the host — e.g.
33/// native app vs web authenticator may omit an app-like segment entirely).
34#[derive(Debug, Clone, uniffi::Object)]
35pub struct UserAgentBuilder {
36    segments: Vec<String>,
37}
38
39#[uniffi::export]
40impl UserAgentBuilder {
41    /// Empty builder — add segments with [`Self::with_segment`] and/or [`Self::with_walletkit_segment`].
42    #[uniffi::constructor]
43    #[must_use]
44    pub const fn new() -> Self {
45        Self {
46            segments: Vec::new(),
47        }
48    }
49
50    /// Appends `{product}/{version}` (e.g. `WorldApp/2.1`, `Chrome/120`). No-op if either side is empty after trim.
51    #[must_use]
52    pub fn with_segment(&self, name: &str, version: &str) -> Self {
53        let mut next = self.clone();
54        next.segments.push(format!("{name}/{version}"));
55        next
56    }
57
58    /// Appends the app product segment for the client name.
59    ///
60    /// Uses `WorldID/{app_version}` for World ID app clients (`android-id` / `ios-id`),
61    /// and `WorldApp/{app_version}` for all other clients.
62    #[must_use]
63    pub fn with_app_segment_for_client(
64        &self,
65        app_version: &str,
66        client_name: &str,
67    ) -> Self {
68        self.with_segment(user_agent_product_for_client(client_name), app_version)
69    }
70
71    /// Appends `walletkit-core/{crate version}`.
72    #[must_use]
73    pub fn with_walletkit_segment(&self) -> Self {
74        let mut next = self.clone();
75        let crate_version = env!("CARGO_PKG_VERSION");
76        next.segments
77            .push(format!("walletkit-core/{crate_version}"));
78        next
79    }
80
81    /// Appends `{client_name}/{os_version}` to match the app client suffix convention.
82    #[must_use]
83    pub fn with_client_segment(&self, client_name: &str, os_version: &str) -> Self {
84        self.with_segment(client_name, os_version)
85    }
86
87    /// Finalizes the header value as [`UserAgent`].
88    #[must_use]
89    pub fn build(&self) -> UserAgent {
90        UserAgent(self.segments.join(" "))
91    }
92}
93
94impl Default for UserAgentBuilder {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100fn user_agent_product_for_client(client_name: &str) -> &'static str {
101    match client_name {
102        WORLD_ID_ANDROID_CLIENT_NAME | WORLD_ID_IOS_CLIENT_NAME => {
103            WORLD_ID_APP_USER_AGENT_PRODUCT
104        }
105        _ => WORLD_APP_USER_AGENT_PRODUCT,
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use test_case::test_case;
113
114    #[test_case(
115        &UserAgentBuilder::new().with_walletkit_segment(),
116        concat!("walletkit-core/", env!("CARGO_PKG_VERSION"));
117        "walletkit_only"
118    )]
119    #[test_case(
120        &UserAgentBuilder::new()
121            .with_segment("WorldApp", "1.0")
122            .with_walletkit_segment(),
123        concat!("WorldApp/1.0 walletkit-core/", env!("CARGO_PKG_VERSION"));
124        "world_app_then_walletkit"
125    )]
126    #[test_case(
127        &UserAgentBuilder::new()
128            .with_segment("WorldApp", "2.1")
129            .with_walletkit_segment()
130            .with_segment("iOS", "17.0"),
131        concat!("WorldApp/2.1 walletkit-core/", env!("CARGO_PKG_VERSION"), " iOS/17.0");
132        "native_style_world_walletkit_os"
133    )]
134    #[test_case(
135        &UserAgentBuilder::new()
136            .with_walletkit_segment()
137            .with_segment("CLI", "1.2.3"),
138        concat!("walletkit-core/", env!("CARGO_PKG_VERSION"), " CLI/1.2.3");
139        "walletkit_then_cli"
140    )]
141    #[test_case(
142        &UserAgentBuilder::new()
143            .with_app_segment_for_client("4.0.2500", "android")
144            .with_walletkit_segment()
145            .with_client_segment("android", "15"),
146        concat!("WorldApp/4.0.2500 walletkit-core/", env!("CARGO_PKG_VERSION"), " android/15");
147        "world_app_android_client"
148    )]
149    #[test_case(
150        &UserAgentBuilder::new()
151            .with_app_segment_for_client("4.0.2500", "ios")
152            .with_walletkit_segment()
153            .with_client_segment("ios", "26.4.2"),
154        concat!("WorldApp/4.0.2500 walletkit-core/", env!("CARGO_PKG_VERSION"), " ios/26.4.2");
155        "world_app_ios_client"
156    )]
157    #[test_case(
158        &UserAgentBuilder::new()
159            .with_app_segment_for_client("1.0.100", "android-id")
160            .with_walletkit_segment()
161            .with_client_segment("android-id", "15"),
162        concat!("WorldID/1.0.100 walletkit-core/", env!("CARGO_PKG_VERSION"), " android-id/15");
163        "world_id_android_client"
164    )]
165    #[test_case(
166        &UserAgentBuilder::new()
167            .with_app_segment_for_client("1.0.100", "ios-id")
168            .with_walletkit_segment()
169            .with_client_segment("ios-id", "26.4.2"),
170        concat!("WorldID/1.0.100 walletkit-core/", env!("CARGO_PKG_VERSION"), " ios-id/26.4.2");
171        "world_id_ios_client"
172    )]
173    fn user_agent_builder_expected(builder: &UserAgentBuilder, expected: &'static str) {
174        assert_eq!(builder.build().to_string(), expected);
175    }
176
177    #[test]
178    fn user_agent_exposes_header_value_for_ffi_consumers() {
179        let user_agent = UserAgentBuilder::new()
180            .with_app_segment_for_client("1.0.100", "android-id")
181            .with_walletkit_segment()
182            .with_client_segment("android-id", "15")
183            .build();
184
185        assert_eq!(
186            user_agent.header_value(),
187            concat!(
188                "WorldID/1.0.100 walletkit-core/",
189                env!("CARGO_PKG_VERSION"),
190                " android-id/15"
191            )
192        );
193    }
194}