aws_runtime/user_agent/
interceptor.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use std::borrow::Cow;
7use std::fmt;
8
9use http_02x::header::{HeaderName, HeaderValue, InvalidHeaderValue, USER_AGENT};
10
11use aws_credential_types::credential_feature::AwsCredentialFeature;
12use aws_smithy_runtime::client::sdk_feature::SmithySdkFeature;
13use aws_smithy_runtime_api::box_error::BoxError;
14use aws_smithy_runtime_api::client::http::HttpClient;
15use aws_smithy_runtime_api::client::interceptors::context::{
16    BeforeTransmitInterceptorContextMut, BeforeTransmitInterceptorContextRef,
17};
18use aws_smithy_runtime_api::client::interceptors::Intercept;
19use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
20use aws_smithy_types::config_bag::ConfigBag;
21use aws_types::app_name::AppName;
22use aws_types::os_shim_internal::Env;
23
24use crate::sdk_feature::AwsSdkFeature;
25use crate::user_agent::metrics::ProvideBusinessMetric;
26use crate::user_agent::{AdditionalMetadata, ApiMetadata, AwsUserAgent, InvalidMetadataValue};
27
28macro_rules! add_metrics_unique {
29    ($features:expr, $ua:expr, $added:expr) => {
30        for feature in $features {
31            if let Some(m) = feature.provide_business_metric() {
32                if !$added.contains(&m) {
33                    $added.insert(m.clone());
34                    $ua.add_business_metric(m);
35                }
36            }
37        }
38    };
39}
40
41macro_rules! add_metrics_unique_reverse {
42    ($features:expr, $ua:expr, $added:expr) => {
43        let mut unique_metrics = Vec::new();
44        for feature in $features {
45            if let Some(m) = feature.provide_business_metric() {
46                if !$added.contains(&m) {
47                    $added.insert(m.clone());
48                    unique_metrics.push(m);
49                }
50            }
51        }
52        for m in unique_metrics.into_iter().rev() {
53            $ua.add_business_metric(m);
54        }
55    };
56}
57
58#[allow(clippy::declare_interior_mutable_const)] // we will never mutate this
59const X_AMZ_USER_AGENT: HeaderName = HeaderName::from_static("x-amz-user-agent");
60
61#[derive(Debug)]
62enum UserAgentInterceptorError {
63    MissingApiMetadata,
64    InvalidHeaderValue(InvalidHeaderValue),
65    InvalidMetadataValue(InvalidMetadataValue),
66}
67
68impl std::error::Error for UserAgentInterceptorError {
69    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
70        match self {
71            Self::InvalidHeaderValue(source) => Some(source),
72            Self::InvalidMetadataValue(source) => Some(source),
73            Self::MissingApiMetadata => None,
74        }
75    }
76}
77
78impl fmt::Display for UserAgentInterceptorError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str(match self {
81            Self::InvalidHeaderValue(_) => "AwsUserAgent generated an invalid HTTP header value. This is a bug. Please file an issue.",
82            Self::InvalidMetadataValue(_) => "AwsUserAgent generated an invalid metadata value. This is a bug. Please file an issue.",
83            Self::MissingApiMetadata => "The UserAgentInterceptor requires ApiMetadata to be set before the request is made. This is a bug. Please file an issue.",
84        })
85    }
86}
87
88impl From<InvalidHeaderValue> for UserAgentInterceptorError {
89    fn from(err: InvalidHeaderValue) -> Self {
90        UserAgentInterceptorError::InvalidHeaderValue(err)
91    }
92}
93
94impl From<InvalidMetadataValue> for UserAgentInterceptorError {
95    fn from(err: InvalidMetadataValue) -> Self {
96        UserAgentInterceptorError::InvalidMetadataValue(err)
97    }
98}
99
100/// Generates and attaches the AWS SDK's user agent to a HTTP request
101#[non_exhaustive]
102#[derive(Debug, Default)]
103pub struct UserAgentInterceptor;
104
105impl UserAgentInterceptor {
106    /// Creates a new `UserAgentInterceptor`
107    pub fn new() -> Self {
108        UserAgentInterceptor
109    }
110}
111
112fn header_values(
113    ua: &AwsUserAgent,
114) -> Result<(HeaderValue, HeaderValue), UserAgentInterceptorError> {
115    // Pay attention to the extremely subtle difference between ua_header and aws_ua_header below...
116    Ok((
117        HeaderValue::try_from(ua.ua_header())?,
118        HeaderValue::try_from(ua.aws_ua_header())?,
119    ))
120}
121
122impl Intercept for UserAgentInterceptor {
123    fn name(&self) -> &'static str {
124        "UserAgentInterceptor"
125    }
126
127    fn read_after_serialization(
128        &self,
129        _context: &BeforeTransmitInterceptorContextRef<'_>,
130        _runtime_components: &RuntimeComponents,
131        cfg: &mut ConfigBag,
132    ) -> Result<(), BoxError> {
133        // Allow for overriding the user agent by an earlier interceptor (so, for example,
134        // tests can use `AwsUserAgent::for_tests()`) by attempting to grab one out of the
135        // config bag before creating one.
136        if cfg.load::<AwsUserAgent>().is_some() {
137            return Ok(());
138        }
139
140        let api_metadata = cfg
141            .load::<ApiMetadata>()
142            .ok_or(UserAgentInterceptorError::MissingApiMetadata)?;
143        let mut ua = AwsUserAgent::new_from_environment(Env::real(), api_metadata.clone());
144
145        let maybe_app_name = cfg.load::<AppName>();
146        if let Some(app_name) = maybe_app_name {
147            ua.set_app_name(app_name.clone());
148        }
149
150        cfg.interceptor_state().store_put(ua);
151
152        Ok(())
153    }
154
155    fn modify_before_signing(
156        &self,
157        context: &mut BeforeTransmitInterceptorContextMut<'_>,
158        runtime_components: &RuntimeComponents,
159        cfg: &mut ConfigBag,
160    ) -> Result<(), BoxError> {
161        let mut ua = cfg
162            .load::<AwsUserAgent>()
163            .expect("`AwsUserAgent should have been created in `read_before_execution`")
164            .clone();
165
166        // Load features from ConfigBag. Note: cfg.load() automatically captures features
167        // from all layers in the ConfigBag, including the interceptor_state layer where
168        // interceptors store their features. There is no need to separately call
169        // cfg.interceptor_state().load() as that would be redundant.
170        let smithy_sdk_features = cfg.load::<SmithySdkFeature>();
171        for smithy_sdk_feature in smithy_sdk_features {
172            smithy_sdk_feature
173                .provide_business_metric()
174                .map(|m| ua.add_business_metric(m));
175        }
176
177        let aws_sdk_features = cfg.load::<AwsSdkFeature>();
178        for aws_sdk_feature in aws_sdk_features {
179            aws_sdk_feature
180                .provide_business_metric()
181                .map(|m| ua.add_business_metric(m));
182        }
183
184        let aws_credential_features = cfg.load::<AwsCredentialFeature>();
185        for aws_credential_feature in aws_credential_features {
186            aws_credential_feature
187                .provide_business_metric()
188                .map(|m| ua.add_business_metric(m));
189        }
190
191        let maybe_connector_metadata = runtime_components
192            .http_client()
193            .and_then(|c| c.connector_metadata());
194        if let Some(connector_metadata) = maybe_connector_metadata {
195            let am = AdditionalMetadata::new(Cow::Owned(connector_metadata.to_string()))?;
196            ua.add_additional_metadata(am);
197        }
198
199        let headers = context.request_mut().headers_mut();
200        let (user_agent, x_amz_user_agent) = header_values(&ua)?;
201        headers.append(USER_AGENT, user_agent);
202        headers.append(X_AMZ_USER_AGENT, x_amz_user_agent);
203        Ok(())
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use aws_smithy_runtime_api::client::interceptors::context::{Input, InterceptorContext};
211    use aws_smithy_runtime_api::client::interceptors::Intercept;
212    use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
213    use aws_smithy_runtime_api::client::runtime_components::RuntimeComponentsBuilder;
214    use aws_smithy_types::config_bag::{ConfigBag, Layer};
215    use aws_smithy_types::error::display::DisplayErrorContext;
216
217    fn expect_header<'a>(context: &'a InterceptorContext, header_name: &str) -> &'a str {
218        context
219            .request()
220            .expect("request is set")
221            .headers()
222            .get(header_name)
223            .unwrap()
224    }
225
226    fn context() -> InterceptorContext {
227        let mut context = InterceptorContext::new(Input::doesnt_matter());
228        context.enter_serialization_phase();
229        context.set_request(HttpRequest::empty());
230        let _ = context.take_input();
231        context.enter_before_transmit_phase();
232        context
233    }
234
235    #[test]
236    fn test_overridden_ua() {
237        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
238        let mut context = context();
239
240        let mut layer = Layer::new("test");
241        layer.store_put(AwsUserAgent::for_tests());
242        layer.store_put(ApiMetadata::new("unused", "unused"));
243        let mut cfg = ConfigBag::of_layers(vec![layer]);
244
245        let interceptor = UserAgentInterceptor::new();
246        let mut ctx = Into::into(&mut context);
247        interceptor
248            .modify_before_signing(&mut ctx, &rc, &mut cfg)
249            .unwrap();
250
251        let header = expect_header(&context, "user-agent");
252        assert_eq!(AwsUserAgent::for_tests().ua_header(), header);
253        assert!(!header.contains("unused"));
254
255        assert_eq!(
256            AwsUserAgent::for_tests().aws_ua_header(),
257            expect_header(&context, "x-amz-user-agent")
258        );
259    }
260
261    #[test]
262    fn test_default_ua() {
263        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
264        let mut context = context();
265
266        let api_metadata = ApiMetadata::new("some-service", "some-version");
267        let mut layer = Layer::new("test");
268        layer.store_put(api_metadata.clone());
269        let mut config = ConfigBag::of_layers(vec![layer]);
270
271        let interceptor = UserAgentInterceptor::new();
272        let ctx = Into::into(&context);
273        interceptor
274            .read_after_serialization(&ctx, &rc, &mut config)
275            .unwrap();
276        let mut ctx = Into::into(&mut context);
277        interceptor
278            .modify_before_signing(&mut ctx, &rc, &mut config)
279            .unwrap();
280
281        let expected_ua = AwsUserAgent::new_from_environment(Env::real(), api_metadata);
282        assert!(
283            expected_ua.aws_ua_header().contains("some-service"),
284            "precondition"
285        );
286        assert_eq!(
287            expected_ua.ua_header(),
288            expect_header(&context, "user-agent")
289        );
290        assert_eq!(
291            expected_ua.aws_ua_header(),
292            expect_header(&context, "x-amz-user-agent")
293        );
294    }
295
296    #[test]
297    fn test_modify_before_signing_no_duplicate_metrics() {
298        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
299        let mut context = context();
300
301        let api_metadata = ApiMetadata::new("test-service", "1.0");
302        let mut layer = Layer::new("test");
303        layer.store_put(api_metadata);
304        // Duplicate features
305        layer.store_append(SmithySdkFeature::Waiter);
306        layer.store_append(SmithySdkFeature::Waiter);
307        layer.store_append(AwsSdkFeature::S3Transfer);
308        layer.store_append(AwsSdkFeature::S3Transfer);
309        layer.store_append(AwsCredentialFeature::CredentialsCode);
310        layer.store_append(AwsCredentialFeature::CredentialsCode);
311        let mut config = ConfigBag::of_layers(vec![layer]);
312
313        let interceptor = UserAgentInterceptor::new();
314        let ctx = Into::into(&context);
315        interceptor
316            .read_after_serialization(&ctx, &rc, &mut config)
317            .unwrap();
318        let mut ctx = Into::into(&mut context);
319        interceptor
320            .modify_before_signing(&mut ctx, &rc, &mut config)
321            .unwrap();
322
323        let aws_ua_header = expect_header(&context, "x-amz-user-agent");
324        let metrics_section = aws_ua_header.split(" m/").nth(1).unwrap();
325        let waiter_count = metrics_section.matches("B").count();
326        let s3_transfer_count = metrics_section.matches("G").count();
327        let credentials_code_count = metrics_section.matches("e").count();
328        assert_eq!(
329            1, waiter_count,
330            "Waiter metric should appear only once, but found {waiter_count} occurrences in: {aws_ua_header}",
331        );
332        assert_eq!(1, s3_transfer_count, "S3Transfer metric should appear only once, but found {s3_transfer_count} occurrences in metrics section: {aws_ua_header}");
333        assert_eq!(1, credentials_code_count, "CredentialsCode metric should appear only once, but found {credentials_code_count} occurrences in metrics section: {aws_ua_header}");
334    }
335
336    #[test]
337    fn test_metrics_order_preserved() {
338        use aws_credential_types::credential_feature::AwsCredentialFeature;
339
340        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
341        let mut context = context();
342
343        let api_metadata = ApiMetadata::new("test-service", "1.0");
344        let mut layer = Layer::new("test");
345        layer.store_put(api_metadata);
346        layer.store_append(AwsCredentialFeature::CredentialsCode);
347        layer.store_append(AwsCredentialFeature::CredentialsEnvVars);
348        layer.store_append(AwsCredentialFeature::CredentialsProfile);
349        let mut config = ConfigBag::of_layers(vec![layer]);
350
351        let interceptor = UserAgentInterceptor::new();
352        let ctx = Into::into(&context);
353        interceptor
354            .read_after_serialization(&ctx, &rc, &mut config)
355            .unwrap();
356        let mut ctx = Into::into(&mut context);
357        interceptor
358            .modify_before_signing(&mut ctx, &rc, &mut config)
359            .unwrap();
360
361        let aws_ua_header = expect_header(&context, "x-amz-user-agent");
362        let metrics_section = aws_ua_header.split(" m/").nth(1).unwrap();
363
364        assert_eq!(
365            metrics_section, "e,g,n",
366            "AwsCredentialFeature metrics should preserve order"
367        );
368    }
369
370    #[test]
371    fn test_app_name() {
372        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
373        let mut context = context();
374
375        let api_metadata = ApiMetadata::new("some-service", "some-version");
376        let mut layer = Layer::new("test");
377        layer.store_put(api_metadata);
378        layer.store_put(AppName::new("my_awesome_app").unwrap());
379        let mut config = ConfigBag::of_layers(vec![layer]);
380
381        let interceptor = UserAgentInterceptor::new();
382        let ctx = Into::into(&context);
383        interceptor
384            .read_after_serialization(&ctx, &rc, &mut config)
385            .unwrap();
386        let mut ctx = Into::into(&mut context);
387        interceptor
388            .modify_before_signing(&mut ctx, &rc, &mut config)
389            .unwrap();
390
391        let app_value = "app/my_awesome_app";
392        let header = expect_header(&context, "user-agent");
393        assert!(
394            !header.contains(app_value),
395            "expected `{header}` to not contain `{app_value}`"
396        );
397
398        let header = expect_header(&context, "x-amz-user-agent");
399        assert!(
400            header.contains(app_value),
401            "expected `{header}` to contain `{app_value}`"
402        );
403    }
404
405    #[test]
406    fn test_api_metadata_missing() {
407        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
408        let context = context();
409        let mut config = ConfigBag::base();
410
411        let interceptor = UserAgentInterceptor::new();
412        let ctx = Into::into(&context);
413
414        let error = format!(
415            "{}",
416            DisplayErrorContext(
417                &*interceptor
418                    .read_after_serialization(&ctx, &rc, &mut config)
419                    .expect_err("it should error")
420            )
421        );
422        assert!(
423            error.contains("This is a bug"),
424            "`{error}` should contain message `This is a bug`"
425        );
426    }
427
428    #[test]
429    fn test_api_metadata_missing_with_ua_override() {
430        let rc = RuntimeComponentsBuilder::for_tests().build().unwrap();
431        let mut context = context();
432
433        let mut layer = Layer::new("test");
434        layer.store_put(AwsUserAgent::for_tests());
435        let mut config = ConfigBag::of_layers(vec![layer]);
436
437        let interceptor = UserAgentInterceptor::new();
438        let mut ctx = Into::into(&mut context);
439
440        interceptor
441            .modify_before_signing(&mut ctx, &rc, &mut config)
442            .expect("it should succeed");
443
444        let header = expect_header(&context, "user-agent");
445        assert_eq!(AwsUserAgent::for_tests().ua_header(), header);
446        assert!(!header.contains("unused"));
447
448        assert_eq!(
449            AwsUserAgent::for_tests().aws_ua_header(),
450            expect_header(&context, "x-amz-user-agent")
451        );
452    }
453
454    #[test]
455    fn test_cfg_load_captures_all_feature_layers() {
456        use crate::sdk_feature::AwsSdkFeature;
457
458        // Create a ConfigBag with features in both base layer and interceptor_state
459        let mut base_layer = Layer::new("base");
460        base_layer.store_append(AwsSdkFeature::EndpointOverride);
461
462        let mut config = ConfigBag::of_layers(vec![base_layer]);
463
464        // Store a feature in interceptor_state (simulating what interceptors do)
465        config
466            .interceptor_state()
467            .store_append(AwsSdkFeature::SsoLoginDevice);
468
469        // Verify that cfg.load() captures features from all layers
470        let all_features: Vec<&AwsSdkFeature> = config.load::<AwsSdkFeature>().collect();
471
472        assert_eq!(
473            all_features.len(),
474            2,
475            "cfg.load() should capture features from all layers"
476        );
477        assert!(
478            all_features.contains(&&AwsSdkFeature::EndpointOverride),
479            "should contain feature from base layer"
480        );
481        assert!(
482            all_features.contains(&&AwsSdkFeature::SsoLoginDevice),
483            "should contain feature from interceptor_state"
484        );
485    }
486}