Unverified Commit 093b65af authored by John DiSanti's avatar John DiSanti Committed by GitHub
Browse files

Implement the `UserAgentInterceptor` for the SDK (#2550)

* Implement the `UserAgentInterceptor` for the SDK

* Refactor interceptor errors

* Centralize config/interceptor registration in codegen
parent 742aae95
Loading
Loading
Loading
Loading
+5 −8
Original line number Diff line number Diff line
@@ -575,9 +575,8 @@ impl From<InvalidHeaderValue> for UserAgentStageError {
    }
}

lazy_static::lazy_static! {
    static ref X_AMZ_USER_AGENT: HeaderName = HeaderName::from_static("x-amz-user-agent");
}
#[allow(clippy::declare_interior_mutable_const)] // we will never mutate this
const X_AMZ_USER_AGENT: HeaderName = HeaderName::from_static("x-amz-user-agent");

impl MapRequest for UserAgentStage {
    type Error = UserAgentStageError;
@@ -593,10 +592,8 @@ impl MapRequest for UserAgentStage {
                .ok_or(UserAgentStageErrorKind::UserAgentMissing)?;
            req.headers_mut()
                .append(USER_AGENT, HeaderValue::try_from(ua.ua_header())?);
            req.headers_mut().append(
                X_AMZ_USER_AGENT.clone(),
                HeaderValue::try_from(ua.aws_ua_header())?,
            );
            req.headers_mut()
                .append(X_AMZ_USER_AGENT, HeaderValue::try_from(ua.aws_ua_header())?);

            Ok(req)
        })
@@ -779,7 +776,7 @@ mod test {
            .get(USER_AGENT)
            .expect("UA header should be set");
        req.headers()
            .get(&*X_AMZ_USER_AGENT)
            .get(&X_AMZ_USER_AGENT)
            .expect("UA header should be set");
    }
}
+4 −1
Original line number Diff line number Diff line
@@ -8,11 +8,14 @@ license = "Apache-2.0"
repository = "https://github.com/awslabs/smithy-rs"

[dependencies]
aws-types = { path = "../aws-types" }
aws-credential-types = { path = "../aws-credential-types" }
aws-http = { path = "../aws-http" }
aws-sigv4 = { path = "../aws-sigv4" }
aws-smithy-http = { path = "../../../rust-runtime/aws-smithy-http" }
aws-smithy-runtime-api = { path = "../../../rust-runtime/aws-smithy-runtime-api" }
aws-smithy-types = { path = "../../../rust-runtime/aws-smithy-types" }
aws-types = { path = "../aws-types" }
http = "0.2.3"
tracing = "0.1"

[dev-dependencies]
+3 −0
Original line number Diff line number Diff line
allowed_external_types = [
    "aws_credential_types::*",
    "aws_sigv4::*",
    "aws_smithy_http::body::SdkBody",
    "aws_smithy_runtime_api::*",
    "aws_types::*",
    "http::request::Request",
    "http::response::Response",
]
+3 −0
Original line number Diff line number Diff line
@@ -18,3 +18,6 @@ pub mod auth;

/// Supporting code for identity in the AWS SDK.
pub mod identity;

/// Supporting code for user agent headers in the AWS SDK.
pub mod user_agent;
+233 −0
Original line number Diff line number Diff line
/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

use aws_http::user_agent::{ApiMetadata, AwsUserAgent};
use aws_smithy_runtime_api::client::interceptors::error::BoxError;
use aws_smithy_runtime_api::client::interceptors::{Interceptor, InterceptorContext};
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
use aws_smithy_runtime_api::config_bag::ConfigBag;
use aws_types::app_name::AppName;
use aws_types::os_shim_internal::Env;
use http::header::{InvalidHeaderValue, USER_AGENT};
use http::{HeaderName, HeaderValue};
use std::borrow::Cow;
use std::fmt;

#[allow(clippy::declare_interior_mutable_const)] // we will never mutate this
const X_AMZ_USER_AGENT: HeaderName = HeaderName::from_static("x-amz-user-agent");

#[derive(Debug)]
enum UserAgentInterceptorError {
    MissingApiMetadata,
    InvalidHeaderValue(InvalidHeaderValue),
}

impl std::error::Error for UserAgentInterceptorError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::InvalidHeaderValue(source) => Some(source),
            Self::MissingApiMetadata => None,
        }
    }
}

impl fmt::Display for UserAgentInterceptorError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::InvalidHeaderValue(_) => "AwsUserAgent generated an invalid HTTP header value. This is a bug. Please file an issue.",
            Self::MissingApiMetadata => "The UserAgentInterceptor requires ApiMetadata to be set before the request is made. This is a bug. Please file an issue.",
        })
    }
}

impl From<InvalidHeaderValue> for UserAgentInterceptorError {
    fn from(err: InvalidHeaderValue) -> Self {
        UserAgentInterceptorError::InvalidHeaderValue(err)
    }
}

/// Generates and attaches the AWS SDK's user agent to a HTTP request
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct UserAgentInterceptor;

impl UserAgentInterceptor {
    /// Creates a new `UserAgentInterceptor`
    pub fn new() -> Self {
        UserAgentInterceptor
    }
}

fn header_values(
    ua: &AwsUserAgent,
) -> Result<(HeaderValue, HeaderValue), UserAgentInterceptorError> {
    // Pay attention to the extremely subtle difference between ua_header and aws_ua_header below...
    Ok((
        HeaderValue::try_from(ua.ua_header())?,
        HeaderValue::try_from(ua.aws_ua_header())?,
    ))
}

impl Interceptor<HttpRequest, HttpResponse> for UserAgentInterceptor {
    fn modify_before_signing(
        &self,
        context: &mut InterceptorContext<HttpRequest, HttpResponse>,
        cfg: &mut ConfigBag,
    ) -> Result<(), BoxError> {
        let api_metadata = cfg
            .get::<ApiMetadata>()
            .ok_or(UserAgentInterceptorError::MissingApiMetadata)?;

        // Allow for overriding the user agent by an earlier interceptor (so, for example,
        // tests can use `AwsUserAgent::for_tests()`) by attempting to grab one out of the
        // config bag before creating one.
        let ua: Cow<'_, AwsUserAgent> = cfg
            .get::<AwsUserAgent>()
            .map(Cow::Borrowed)
            .unwrap_or_else(|| {
                let mut ua = AwsUserAgent::new_from_environment(Env::real(), api_metadata.clone());

                let maybe_app_name = cfg.get::<AppName>();
                if let Some(app_name) = maybe_app_name {
                    ua.set_app_name(app_name.clone());
                }
                Cow::Owned(ua)
            });

        let headers = context.request_mut()?.headers_mut();
        let (user_agent, x_amz_user_agent) = header_values(&ua)?;
        headers.append(USER_AGENT, user_agent);
        headers.append(X_AMZ_USER_AGENT, x_amz_user_agent);
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use aws_smithy_http::body::SdkBody;
    use aws_smithy_runtime_api::client::interceptors::{Interceptor, InterceptorContext};
    use aws_smithy_runtime_api::config_bag::ConfigBag;
    use aws_smithy_runtime_api::type_erasure::TypedBox;
    use aws_smithy_types::error::display::DisplayErrorContext;

    fn expect_header<'a>(
        context: &'a InterceptorContext<HttpRequest, HttpResponse>,
        header_name: &str,
    ) -> &'a str {
        context
            .request()
            .unwrap()
            .headers()
            .get(header_name)
            .unwrap()
            .to_str()
            .unwrap()
    }

    #[test]
    fn test_overridden_ua() {
        let mut context = InterceptorContext::new(TypedBox::new("doesntmatter").erase());
        context.set_request(http::Request::builder().body(SdkBody::empty()).unwrap());

        let mut config = ConfigBag::base();
        config.put(AwsUserAgent::for_tests());
        config.put(ApiMetadata::new("unused", "unused"));

        let interceptor = UserAgentInterceptor::new();
        interceptor
            .modify_before_signing(&mut context, &mut config)
            .unwrap();

        let header = expect_header(&context, "user-agent");
        assert_eq!(AwsUserAgent::for_tests().ua_header(), header);
        assert!(!header.contains("unused"));

        assert_eq!(
            AwsUserAgent::for_tests().aws_ua_header(),
            expect_header(&context, "x-amz-user-agent")
        );
    }

    #[test]
    fn test_default_ua() {
        let mut context = InterceptorContext::new(TypedBox::new("doesntmatter").erase());
        context.set_request(http::Request::builder().body(SdkBody::empty()).unwrap());

        let api_metadata = ApiMetadata::new("some-service", "some-version");
        let mut config = ConfigBag::base();
        config.put(api_metadata.clone());

        let interceptor = UserAgentInterceptor::new();
        interceptor
            .modify_before_signing(&mut context, &mut config)
            .unwrap();

        let expected_ua = AwsUserAgent::new_from_environment(Env::real(), api_metadata);
        assert!(
            expected_ua.aws_ua_header().contains("some-service"),
            "precondition"
        );
        assert_eq!(
            expected_ua.ua_header(),
            expect_header(&context, "user-agent")
        );
        assert_eq!(
            expected_ua.aws_ua_header(),
            expect_header(&context, "x-amz-user-agent")
        );
    }

    #[test]
    fn test_app_name() {
        let mut context = InterceptorContext::new(TypedBox::new("doesntmatter").erase());
        context.set_request(http::Request::builder().body(SdkBody::empty()).unwrap());

        let api_metadata = ApiMetadata::new("some-service", "some-version");
        let mut config = ConfigBag::base();
        config.put(api_metadata.clone());
        config.put(AppName::new("my_awesome_app").unwrap());

        let interceptor = UserAgentInterceptor::new();
        interceptor
            .modify_before_signing(&mut context, &mut config)
            .unwrap();

        let app_value = "app/my_awesome_app";
        let header = expect_header(&context, "user-agent");
        assert!(
            !header.contains(app_value),
            "expected `{header}` to not contain `{app_value}`"
        );

        let header = expect_header(&context, "x-amz-user-agent");
        assert!(
            header.contains(app_value),
            "expected `{header}` to contain `{app_value}`"
        );
    }

    #[test]
    fn test_api_metadata_missing() {
        let mut context = InterceptorContext::new(TypedBox::new("doesntmatter").erase());
        context.set_request(http::Request::builder().body(SdkBody::empty()).unwrap());

        let mut config = ConfigBag::base();

        let interceptor = UserAgentInterceptor::new();
        let error = format!(
            "{}",
            DisplayErrorContext(
                &*interceptor
                    .modify_before_signing(&mut context, &mut config)
                    .expect_err("it should error")
            )
        );
        assert!(
            error.contains("This is a bug"),
            "`{error}` should contain message `This is a bug`"
        );
    }
}
Loading