Skip to main content

azure_core/http/policies/
user_agent.rs

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT License.
3
4use crate::http::{
5    headers::{HeaderValue, USER_AGENT},
6    options::UserAgentOptions,
7};
8use std::{
9    env::consts::{ARCH, OS},
10    sync::Arc,
11};
12use typespec_client_core::http::{
13    policies::{Policy, PolicyResult},
14    Context, Request,
15};
16
17/// Sets the `User-Agent` header with useful information in a typical format for Azure SDKs.
18#[derive(Clone, Debug)]
19pub struct UserAgentPolicy {
20    header: HeaderValue,
21}
22
23impl<'a> UserAgentPolicy {
24    /// Create a new `UserAgentPolicy`.
25    ///
26    /// # Panics
27    ///
28    /// Panics if [`UserAgentOptions::application_id`] is greater than 24 characters.
29    /// See [guidelines](https://azure.github.io/azure-sdk/general_azurecore.html#azurecore-http-telemetry-appid-length) for details.
30    ///
31    /// Panics if [`UserAgentOptions::application_id`] contains invalid characters.
32    /// Only RFC 9110 "tchar" tokens are allowed.
33    pub fn new(
34        crate_name: Option<&'a str>,
35        crate_version: Option<&'a str>,
36        options: &UserAgentOptions,
37    ) -> Self {
38        Self::new_with_rustc_version(
39            crate_name,
40            crate_version,
41            option_env!("AZSDK_RUSTC_VERSION"),
42            options,
43        )
44    }
45
46    fn new_with_rustc_version(
47        crate_name: Option<&'a str>,
48        crate_version: Option<&'a str>,
49        rustc_version: Option<&'a str>,
50        options: &UserAgentOptions,
51    ) -> Self {
52        const UNKNOWN: &str = "unknown";
53        let mut crate_name = crate_name.unwrap_or(UNKNOWN);
54        let crate_version = crate_version.unwrap_or(UNKNOWN);
55        let rustc_version = rustc_version.unwrap_or(UNKNOWN);
56        let platform_info = format!("({rustc_version}; {OS}; {ARCH})",);
57
58        if let Some(name) = crate_name.strip_prefix("azure_") {
59            crate_name = name;
60        }
61
62        const MAX_APPLICATION_ID_LEN: usize = 24;
63        let header_str = match &options.application_id {
64            Some(application_id) => {
65                if application_id.len() > MAX_APPLICATION_ID_LEN {
66                    panic!(
67                        "application_id must be shorter than {} characters",
68                        MAX_APPLICATION_ID_LEN + 1
69                    );
70                }
71
72                // RFC 9110 tchar validation
73                // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
74                //       / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
75                //       / DIGIT / ALPHA
76                if !application_id.chars().all(|c| {
77                    c.is_ascii_alphanumeric()
78                        || matches!(
79                            c,
80                            '!' | '#'
81                                | '$'
82                                | '%'
83                                | '&'
84                                | '\''
85                                | '*'
86                                | '+'
87                                | '-'
88                                | '.'
89                                | '^'
90                                | '_'
91                                | '`'
92                                | '|'
93                                | '~'
94                        )
95                }) {
96                    panic!("application_id contains invalid characters. Only RFC 9110 tokens are allowed.");
97                }
98
99                format!("{application_id} azsdk-rust-{crate_name}/{crate_version} {platform_info}")
100            }
101            None => format!("azsdk-rust-{crate_name}/{crate_version} {platform_info}"),
102        };
103
104        UserAgentPolicy {
105            header: HeaderValue::from(header_str),
106        }
107    }
108}
109
110#[async_trait::async_trait]
111impl Policy for UserAgentPolicy {
112    async fn send(
113        &self,
114        ctx: &Context,
115        request: &mut Request,
116        next: &[Arc<dyn Policy>],
117    ) -> PolicyResult {
118        request.insert_header(USER_AGENT, self.header.clone());
119        next[0].send(ctx, request, &next[1..]).await
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn without_application_id() {
129        let policy = UserAgentPolicy::new_with_rustc_version(
130            Some("azure_test"), // Tests that "azure_" is removed.
131            Some("1.2.3"),
132            Some("4.5.6"),
133            &UserAgentOptions::default(),
134        );
135        assert_eq!(
136            policy.header.as_str(),
137            format!("azsdk-rust-test/1.2.3 (4.5.6; {OS}; {ARCH})")
138        );
139    }
140
141    #[test]
142    fn with_application_id() {
143        let options = UserAgentOptions {
144            application_id: Some("my_app".to_string()),
145        };
146        let policy = UserAgentPolicy::new_with_rustc_version(
147            Some("test"),
148            Some("1.2.3"),
149            Some("4.5.6"),
150            &options,
151        );
152        assert_eq!(
153            policy.header.as_str(),
154            format!("my_app azsdk-rust-test/1.2.3 (4.5.6; {OS}; {ARCH})")
155        );
156    }
157
158    #[test]
159    fn missing_env() {
160        // Would simulate if option_env!("CARGO_PKG_NAME"), for example, returned None.
161        let policy =
162            UserAgentPolicy::new_with_rustc_version(None, None, None, &UserAgentOptions::default());
163        assert_eq!(
164            policy.header.as_str(),
165            format!("azsdk-rust-unknown/unknown (unknown; {OS}; {ARCH})")
166        );
167    }
168
169    #[test]
170    #[should_panic(expected = "application_id must be shorter than 25 characters")]
171    fn panics_when_application_id_too_long() {
172        let options = UserAgentOptions {
173            application_id: Some(
174                "this_application_id_is_way_too_long_and_exceeds_limit".to_string(),
175            ), // 53 characters
176        };
177        let _policy = UserAgentPolicy::new_with_rustc_version(
178            Some("test"),
179            Some("1.2.3"),
180            Some("4.5.6"),
181            &options,
182        );
183    }
184
185    #[test]
186    fn works_with_application_id_at_limit() {
187        let options = UserAgentOptions {
188            application_id: Some("exactly_24_characters!!!".to_string()), // Exactly 24 characters
189        };
190        let policy = UserAgentPolicy::new_with_rustc_version(
191            Some("test"),
192            Some("1.2.3"),
193            Some("4.5.6"),
194            &options,
195        );
196        assert_eq!(
197            policy.header.as_str(),
198            format!("exactly_24_characters!!! azsdk-rust-test/1.2.3 (4.5.6; {OS}; {ARCH})")
199        );
200    }
201
202    #[test]
203    #[should_panic(
204        expected = "application_id contains invalid characters. Only RFC 9110 tokens are allowed."
205    )]
206    fn test_user_agent_invalid_chars() {
207        // "Space" is not allowed in tchar.
208        let options = UserAgentOptions {
209            application_id: Some("invalid application id".to_string()),
210        };
211        let _policy = UserAgentPolicy::new_with_rustc_version(
212            Some("test"),
213            Some("1.2.3"),
214            Some("4.5.6"),
215            &options,
216        );
217    }
218}