Skip to content

Commit 093b65a

Browse files
authored
Implement the UserAgentInterceptor for the SDK (#2550)
* Implement the `UserAgentInterceptor` for the SDK * Refactor interceptor errors * Centralize config/interceptor registration in codegen
1 parent 742aae9 commit 093b65a

File tree

13 files changed

+459
-351
lines changed

13 files changed

+459
-351
lines changed

aws/rust-runtime/aws-http/src/user_agent.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -575,9 +575,8 @@ impl From<InvalidHeaderValue> for UserAgentStageError {
575575
}
576576
}
577577

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

582581
impl MapRequest for UserAgentStage {
583582
type Error = UserAgentStageError;
@@ -593,10 +592,8 @@ impl MapRequest for UserAgentStage {
593592
.ok_or(UserAgentStageErrorKind::UserAgentMissing)?;
594593
req.headers_mut()
595594
.append(USER_AGENT, HeaderValue::try_from(ua.ua_header())?);
596-
req.headers_mut().append(
597-
X_AMZ_USER_AGENT.clone(),
598-
HeaderValue::try_from(ua.aws_ua_header())?,
599-
);
595+
req.headers_mut()
596+
.append(X_AMZ_USER_AGENT, HeaderValue::try_from(ua.aws_ua_header())?);
600597

601598
Ok(req)
602599
})
@@ -779,7 +776,7 @@ mod test {
779776
.get(USER_AGENT)
780777
.expect("UA header should be set");
781778
req.headers()
782-
.get(&*X_AMZ_USER_AGENT)
779+
.get(&X_AMZ_USER_AGENT)
783780
.expect("UA header should be set");
784781
}
785782
}

aws/rust-runtime/aws-runtime/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ license = "Apache-2.0"
88
repository = "https://github.com/awslabs/smithy-rs"
99

1010
[dependencies]
11-
aws-types = { path = "../aws-types" }
1211
aws-credential-types = { path = "../aws-credential-types" }
12+
aws-http = { path = "../aws-http" }
1313
aws-sigv4 = { path = "../aws-sigv4" }
1414
aws-smithy-http = { path = "../../../rust-runtime/aws-smithy-http" }
1515
aws-smithy-runtime-api = { path = "../../../rust-runtime/aws-smithy-runtime-api" }
16+
aws-smithy-types = { path = "../../../rust-runtime/aws-smithy-types" }
17+
aws-types = { path = "../aws-types" }
18+
http = "0.2.3"
1619
tracing = "0.1"
1720

1821
[dev-dependencies]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
allowed_external_types = [
22
"aws_credential_types::*",
33
"aws_sigv4::*",
4+
"aws_smithy_http::body::SdkBody",
45
"aws_smithy_runtime_api::*",
56
"aws_types::*",
7+
"http::request::Request",
8+
"http::response::Response",
69
]

aws/rust-runtime/aws-runtime/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ pub mod auth;
1818

1919
/// Supporting code for identity in the AWS SDK.
2020
pub mod identity;
21+
22+
/// Supporting code for user agent headers in the AWS SDK.
23+
pub mod user_agent;
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
use aws_http::user_agent::{ApiMetadata, AwsUserAgent};
7+
use aws_smithy_runtime_api::client::interceptors::error::BoxError;
8+
use aws_smithy_runtime_api::client::interceptors::{Interceptor, InterceptorContext};
9+
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
10+
use aws_smithy_runtime_api::config_bag::ConfigBag;
11+
use aws_types::app_name::AppName;
12+
use aws_types::os_shim_internal::Env;
13+
use http::header::{InvalidHeaderValue, USER_AGENT};
14+
use http::{HeaderName, HeaderValue};
15+
use std::borrow::Cow;
16+
use std::fmt;
17+
18+
#[allow(clippy::declare_interior_mutable_const)] // we will never mutate this
19+
const X_AMZ_USER_AGENT: HeaderName = HeaderName::from_static("x-amz-user-agent");
20+
21+
#[derive(Debug)]
22+
enum UserAgentInterceptorError {
23+
MissingApiMetadata,
24+
InvalidHeaderValue(InvalidHeaderValue),
25+
}
26+
27+
impl std::error::Error for UserAgentInterceptorError {
28+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
29+
match self {
30+
Self::InvalidHeaderValue(source) => Some(source),
31+
Self::MissingApiMetadata => None,
32+
}
33+
}
34+
}
35+
36+
impl fmt::Display for UserAgentInterceptorError {
37+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38+
f.write_str(match self {
39+
Self::InvalidHeaderValue(_) => "AwsUserAgent generated an invalid HTTP header value. This is a bug. Please file an issue.",
40+
Self::MissingApiMetadata => "The UserAgentInterceptor requires ApiMetadata to be set before the request is made. This is a bug. Please file an issue.",
41+
})
42+
}
43+
}
44+
45+
impl From<InvalidHeaderValue> for UserAgentInterceptorError {
46+
fn from(err: InvalidHeaderValue) -> Self {
47+
UserAgentInterceptorError::InvalidHeaderValue(err)
48+
}
49+
}
50+
51+
/// Generates and attaches the AWS SDK's user agent to a HTTP request
52+
#[non_exhaustive]
53+
#[derive(Debug, Default)]
54+
pub struct UserAgentInterceptor;
55+
56+
impl UserAgentInterceptor {
57+
/// Creates a new `UserAgentInterceptor`
58+
pub fn new() -> Self {
59+
UserAgentInterceptor
60+
}
61+
}
62+
63+
fn header_values(
64+
ua: &AwsUserAgent,
65+
) -> Result<(HeaderValue, HeaderValue), UserAgentInterceptorError> {
66+
// Pay attention to the extremely subtle difference between ua_header and aws_ua_header below...
67+
Ok((
68+
HeaderValue::try_from(ua.ua_header())?,
69+
HeaderValue::try_from(ua.aws_ua_header())?,
70+
))
71+
}
72+
73+
impl Interceptor<HttpRequest, HttpResponse> for UserAgentInterceptor {
74+
fn modify_before_signing(
75+
&self,
76+
context: &mut InterceptorContext<HttpRequest, HttpResponse>,
77+
cfg: &mut ConfigBag,
78+
) -> Result<(), BoxError> {
79+
let api_metadata = cfg
80+
.get::<ApiMetadata>()
81+
.ok_or(UserAgentInterceptorError::MissingApiMetadata)?;
82+
83+
// Allow for overriding the user agent by an earlier interceptor (so, for example,
84+
// tests can use `AwsUserAgent::for_tests()`) by attempting to grab one out of the
85+
// config bag before creating one.
86+
let ua: Cow<'_, AwsUserAgent> = cfg
87+
.get::<AwsUserAgent>()
88+
.map(Cow::Borrowed)
89+
.unwrap_or_else(|| {
90+
let mut ua = AwsUserAgent::new_from_environment(Env::real(), api_metadata.clone());
91+
92+
let maybe_app_name = cfg.get::<AppName>();
93+
if let Some(app_name) = maybe_app_name {
94+
ua.set_app_name(app_name.clone());
95+
}
96+
Cow::Owned(ua)
97+
});
98+
99+
let headers = context.request_mut()?.headers_mut();
100+
let (user_agent, x_amz_user_agent) = header_values(&ua)?;
101+
headers.append(USER_AGENT, user_agent);
102+
headers.append(X_AMZ_USER_AGENT, x_amz_user_agent);
103+
Ok(())
104+
}
105+
}
106+
107+
#[cfg(test)]
108+
mod tests {
109+
use super::*;
110+
use aws_smithy_http::body::SdkBody;
111+
use aws_smithy_runtime_api::client::interceptors::{Interceptor, InterceptorContext};
112+
use aws_smithy_runtime_api::config_bag::ConfigBag;
113+
use aws_smithy_runtime_api::type_erasure::TypedBox;
114+
use aws_smithy_types::error::display::DisplayErrorContext;
115+
116+
fn expect_header<'a>(
117+
context: &'a InterceptorContext<HttpRequest, HttpResponse>,
118+
header_name: &str,
119+
) -> &'a str {
120+
context
121+
.request()
122+
.unwrap()
123+
.headers()
124+
.get(header_name)
125+
.unwrap()
126+
.to_str()
127+
.unwrap()
128+
}
129+
130+
#[test]
131+
fn test_overridden_ua() {
132+
let mut context = InterceptorContext::new(TypedBox::new("doesntmatter").erase());
133+
context.set_request(http::Request::builder().body(SdkBody::empty()).unwrap());
134+
135+
let mut config = ConfigBag::base();
136+
config.put(AwsUserAgent::for_tests());
137+
config.put(ApiMetadata::new("unused", "unused"));
138+
139+
let interceptor = UserAgentInterceptor::new();
140+
interceptor
141+
.modify_before_signing(&mut context, &mut config)
142+
.unwrap();
143+
144+
let header = expect_header(&context, "user-agent");
145+
assert_eq!(AwsUserAgent::for_tests().ua_header(), header);
146+
assert!(!header.contains("unused"));
147+
148+
assert_eq!(
149+
AwsUserAgent::for_tests().aws_ua_header(),
150+
expect_header(&context, "x-amz-user-agent")
151+
);
152+
}
153+
154+
#[test]
155+
fn test_default_ua() {
156+
let mut context = InterceptorContext::new(TypedBox::new("doesntmatter").erase());
157+
context.set_request(http::Request::builder().body(SdkBody::empty()).unwrap());
158+
159+
let api_metadata = ApiMetadata::new("some-service", "some-version");
160+
let mut config = ConfigBag::base();
161+
config.put(api_metadata.clone());
162+
163+
let interceptor = UserAgentInterceptor::new();
164+
interceptor
165+
.modify_before_signing(&mut context, &mut config)
166+
.unwrap();
167+
168+
let expected_ua = AwsUserAgent::new_from_environment(Env::real(), api_metadata);
169+
assert!(
170+
expected_ua.aws_ua_header().contains("some-service"),
171+
"precondition"
172+
);
173+
assert_eq!(
174+
expected_ua.ua_header(),
175+
expect_header(&context, "user-agent")
176+
);
177+
assert_eq!(
178+
expected_ua.aws_ua_header(),
179+
expect_header(&context, "x-amz-user-agent")
180+
);
181+
}
182+
183+
#[test]
184+
fn test_app_name() {
185+
let mut context = InterceptorContext::new(TypedBox::new("doesntmatter").erase());
186+
context.set_request(http::Request::builder().body(SdkBody::empty()).unwrap());
187+
188+
let api_metadata = ApiMetadata::new("some-service", "some-version");
189+
let mut config = ConfigBag::base();
190+
config.put(api_metadata.clone());
191+
config.put(AppName::new("my_awesome_app").unwrap());
192+
193+
let interceptor = UserAgentInterceptor::new();
194+
interceptor
195+
.modify_before_signing(&mut context, &mut config)
196+
.unwrap();
197+
198+
let app_value = "app/my_awesome_app";
199+
let header = expect_header(&context, "user-agent");
200+
assert!(
201+
!header.contains(app_value),
202+
"expected `{header}` to not contain `{app_value}`"
203+
);
204+
205+
let header = expect_header(&context, "x-amz-user-agent");
206+
assert!(
207+
header.contains(app_value),
208+
"expected `{header}` to contain `{app_value}`"
209+
);
210+
}
211+
212+
#[test]
213+
fn test_api_metadata_missing() {
214+
let mut context = InterceptorContext::new(TypedBox::new("doesntmatter").erase());
215+
context.set_request(http::Request::builder().body(SdkBody::empty()).unwrap());
216+
217+
let mut config = ConfigBag::base();
218+
219+
let interceptor = UserAgentInterceptor::new();
220+
let error = format!(
221+
"{}",
222+
DisplayErrorContext(
223+
&*interceptor
224+
.modify_before_signing(&mut context, &mut config)
225+
.expect_err("it should error")
226+
)
227+
);
228+
assert!(
229+
error.contains("This is a bug"),
230+
"`{error}` should contain message `This is a bug`"
231+
);
232+
}
233+
}

aws/sdk-codegen/src/main/kotlin/software/amazon/smithy/rustsdk/SigV4AuthDecorator.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,21 @@ private class AuthServiceRuntimePluginCustomization(codegenContext: ClientCodege
7070
}
7171

7272
is ServiceRuntimePluginSection.AdditionalConfig -> {
73+
section.putConfigValue(this) {
74+
rustTemplate("#{SigningService}::from_static(self.handle.conf.signing_service())", *codegenScope)
75+
}
7376
rustTemplate(
7477
"""
75-
cfg.put(#{SigningService}::from_static(self.handle.conf.signing_service()));
7678
if let Some(region) = self.handle.conf.region() {
77-
cfg.put(#{SigningRegion}::from(region.clone()));
79+
#{put_signing_region}
7880
}
7981
""",
8082
*codegenScope,
83+
"put_signing_region" to writable {
84+
section.putConfigValue(this) {
85+
rustTemplate("#{SigningRegion}::from(region.clone())", *codegenScope)
86+
}
87+
},
8188
)
8289
}
8390

aws/sdk-codegen/src/main/kotlin/software/amazon/smithy/rustsdk/UserAgentDecorator.kt

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import software.amazon.smithy.model.shapes.OperationShape
1010
import software.amazon.smithy.rust.codegen.client.smithy.ClientCodegenContext
1111
import software.amazon.smithy.rust.codegen.client.smithy.ClientRustModule
1212
import software.amazon.smithy.rust.codegen.client.smithy.customize.ClientCodegenDecorator
13+
import software.amazon.smithy.rust.codegen.client.smithy.generators.ServiceRuntimePluginCustomization
14+
import software.amazon.smithy.rust.codegen.client.smithy.generators.ServiceRuntimePluginSection
1315
import software.amazon.smithy.rust.codegen.client.smithy.generators.config.ConfigCustomization
1416
import software.amazon.smithy.rust.codegen.client.smithy.generators.config.ServiceConfig
1517
import software.amazon.smithy.rust.codegen.core.rustlang.Writable
@@ -25,6 +27,7 @@ import software.amazon.smithy.rust.codegen.core.smithy.customize.OperationSectio
2527
import software.amazon.smithy.rust.codegen.core.smithy.customize.adhocCustomization
2628
import software.amazon.smithy.rust.codegen.core.util.dq
2729
import software.amazon.smithy.rust.codegen.core.util.expectTrait
30+
import software.amazon.smithy.rust.codegen.core.util.letIf
2831

2932
/**
3033
* Inserts a UserAgent configuration into the operation
@@ -45,9 +48,17 @@ class UserAgentDecorator : ClientCodegenDecorator {
4548
operation: OperationShape,
4649
baseCustomizations: List<OperationCustomization>,
4750
): List<OperationCustomization> {
48-
return baseCustomizations + UserAgentFeature(codegenContext)
51+
return baseCustomizations + UserAgentMutateOpRequest(codegenContext)
4952
}
5053

54+
override fun serviceRuntimePluginCustomizations(
55+
codegenContext: ClientCodegenContext,
56+
baseCustomizations: List<ServiceRuntimePluginCustomization>,
57+
): List<ServiceRuntimePluginCustomization> =
58+
baseCustomizations.letIf(codegenContext.settings.codegenConfig.enableNewSmithyRuntime) {
59+
it + listOf(AddApiMetadataIntoConfigBag(codegenContext))
60+
}
61+
5162
override fun extraSections(codegenContext: ClientCodegenContext): List<AdHocCustomization> {
5263
return listOf(
5364
adhocCustomization<SdkConfigSection.CopySdkConfigToClientConfig> { section ->
@@ -86,8 +97,26 @@ class UserAgentDecorator : ClientCodegenDecorator {
8697
}
8798
}
8899

89-
private class UserAgentFeature(
90-
private val codegenContext: ClientCodegenContext,
100+
private class AddApiMetadataIntoConfigBag(codegenContext: ClientCodegenContext) :
101+
ServiceRuntimePluginCustomization() {
102+
private val runtimeConfig = codegenContext.runtimeConfig
103+
private val awsRuntime = AwsRuntimeType.awsRuntime(runtimeConfig)
104+
105+
override fun section(section: ServiceRuntimePluginSection): Writable = writable {
106+
if (section is ServiceRuntimePluginSection.AdditionalConfig) {
107+
section.putConfigValue(this) {
108+
rust("#T.clone()", ClientRustModule.Meta.toType().resolve("API_METADATA"))
109+
}
110+
section.registerInterceptor(runtimeConfig, this) {
111+
rust("#T::new()", awsRuntime.resolve("user_agent::UserAgentInterceptor"))
112+
}
113+
}
114+
}
115+
}
116+
117+
// TODO(enableNewSmithyRuntime): Remove this customization class
118+
private class UserAgentMutateOpRequest(
119+
codegenContext: ClientCodegenContext,
91120
) : OperationCustomization() {
92121
private val runtimeConfig = codegenContext.runtimeConfig
93122

0 commit comments

Comments
 (0)