Skip to content

Commit

Permalink
Implement the UserAgentInterceptor for the SDK (smithy-lang#2550)
Browse files Browse the repository at this point in the history
* Implement the `UserAgentInterceptor` for the SDK

* Refactor interceptor errors

* Centralize config/interceptor registration in codegen
  • Loading branch information
jdisanti authored Apr 7, 2023
1 parent 742aae9 commit 093b65a
Show file tree
Hide file tree
Showing 13 changed files with 459 additions and 351 deletions.
13 changes: 5 additions & 8 deletions aws/rust-runtime/aws-http/src/user_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
})
Expand Down Expand Up @@ -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");
}
}
Expand Down
5 changes: 4 additions & 1 deletion aws/rust-runtime/aws-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions aws/rust-runtime/aws-runtime/external-types.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
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 changes: 3 additions & 0 deletions aws/rust-runtime/aws-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 233 additions & 0 deletions aws/rust-runtime/aws-runtime/src/user_agent.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/*
* 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`"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,21 @@ private class AuthServiceRuntimePluginCustomization(codegenContext: ClientCodege
}

is ServiceRuntimePluginSection.AdditionalConfig -> {
section.putConfigValue(this) {
rustTemplate("#{SigningService}::from_static(self.handle.conf.signing_service())", *codegenScope)
}
rustTemplate(
"""
cfg.put(#{SigningService}::from_static(self.handle.conf.signing_service()));
if let Some(region) = self.handle.conf.region() {
cfg.put(#{SigningRegion}::from(region.clone()));
#{put_signing_region}
}
""",
*codegenScope,
"put_signing_region" to writable {
section.putConfigValue(this) {
rustTemplate("#{SigningRegion}::from(region.clone())", *codegenScope)
}
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import software.amazon.smithy.model.shapes.OperationShape
import software.amazon.smithy.rust.codegen.client.smithy.ClientCodegenContext
import software.amazon.smithy.rust.codegen.client.smithy.ClientRustModule
import software.amazon.smithy.rust.codegen.client.smithy.customize.ClientCodegenDecorator
import software.amazon.smithy.rust.codegen.client.smithy.generators.ServiceRuntimePluginCustomization
import software.amazon.smithy.rust.codegen.client.smithy.generators.ServiceRuntimePluginSection
import software.amazon.smithy.rust.codegen.client.smithy.generators.config.ConfigCustomization
import software.amazon.smithy.rust.codegen.client.smithy.generators.config.ServiceConfig
import software.amazon.smithy.rust.codegen.core.rustlang.Writable
Expand All @@ -25,6 +27,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.customize.OperationSectio
import software.amazon.smithy.rust.codegen.core.smithy.customize.adhocCustomization
import software.amazon.smithy.rust.codegen.core.util.dq
import software.amazon.smithy.rust.codegen.core.util.expectTrait
import software.amazon.smithy.rust.codegen.core.util.letIf

/**
* Inserts a UserAgent configuration into the operation
Expand All @@ -45,9 +48,17 @@ class UserAgentDecorator : ClientCodegenDecorator {
operation: OperationShape,
baseCustomizations: List<OperationCustomization>,
): List<OperationCustomization> {
return baseCustomizations + UserAgentFeature(codegenContext)
return baseCustomizations + UserAgentMutateOpRequest(codegenContext)
}

override fun serviceRuntimePluginCustomizations(
codegenContext: ClientCodegenContext,
baseCustomizations: List<ServiceRuntimePluginCustomization>,
): List<ServiceRuntimePluginCustomization> =
baseCustomizations.letIf(codegenContext.settings.codegenConfig.enableNewSmithyRuntime) {
it + listOf(AddApiMetadataIntoConfigBag(codegenContext))
}

override fun extraSections(codegenContext: ClientCodegenContext): List<AdHocCustomization> {
return listOf(
adhocCustomization<SdkConfigSection.CopySdkConfigToClientConfig> { section ->
Expand Down Expand Up @@ -86,8 +97,26 @@ class UserAgentDecorator : ClientCodegenDecorator {
}
}

private class UserAgentFeature(
private val codegenContext: ClientCodegenContext,
private class AddApiMetadataIntoConfigBag(codegenContext: ClientCodegenContext) :
ServiceRuntimePluginCustomization() {
private val runtimeConfig = codegenContext.runtimeConfig
private val awsRuntime = AwsRuntimeType.awsRuntime(runtimeConfig)

override fun section(section: ServiceRuntimePluginSection): Writable = writable {
if (section is ServiceRuntimePluginSection.AdditionalConfig) {
section.putConfigValue(this) {
rust("#T.clone()", ClientRustModule.Meta.toType().resolve("API_METADATA"))
}
section.registerInterceptor(runtimeConfig, this) {
rust("#T::new()", awsRuntime.resolve("user_agent::UserAgentInterceptor"))
}
}
}
}

// TODO(enableNewSmithyRuntime): Remove this customization class
private class UserAgentMutateOpRequest(
codegenContext: ClientCodegenContext,
) : OperationCustomization() {
private val runtimeConfig = codegenContext.runtimeConfig

Expand Down
Loading

0 comments on commit 093b65a

Please sign in to comment.