Skip to content

Commit 05a972a

Browse files
committed
Feature ID implementation for S3 Express Bucket
1 parent 1c0b19f commit 05a972a

File tree

5 files changed

+285
-0
lines changed

5 files changed

+285
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "feature",
3+
"category": "AWS SDK for Java v2",
4+
"contributor": "",
5+
"description": "Implemented business metrics tracking for S3_Express_Bucket (featureID \"J\") through User-Agent header."
6+
}

codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverInterceptorSpec.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,12 @@ private MethodSpec setMetricValuesMethod() {
921921
+ "metrics -> endpoint.attribute($T.METRIC_VALUES).forEach(v -> metrics.addMetric(v)))",
922922
SdkInternalExecutionAttribute.class, AwsEndpointAttribute.class);
923923
b.endControlFlow();
924+
925+
if (endpointRulesSpecUtils.isS3()) {
926+
b.addStatement("$T.addS3ExpressBusinessMetricIfApplicable(executionAttributes)",
927+
ClassName.get("software.amazon.awssdk.services.s3.internal.s3express", "S3ExpressUtils"));
928+
}
929+
924930
return b.build();
925931
}
926932
}

core/sdk-core/src/main/java/software/amazon/awssdk/core/useragent/BusinessMetricFeatureId.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public enum BusinessMetricFeatureId {
3636
S3_TRANSFER("G"),
3737
GZIP_REQUEST_COMPRESSION("L"), //TODO(metrics): Not working, compression happens after header
3838
ENDPOINT_OVERRIDE("N"),
39+
S3_EXPRESS_BUCKET("J"),
3940
ACCOUNT_ID_MODE_PREFERRED("P"),
4041
ACCOUNT_ID_MODE_DISABLED("Q"),
4142
ACCOUNT_ID_MODE_REQUIRED("R"),

services/s3/src/main/java/software/amazon/awssdk/services/s3/internal/s3express/S3ExpressUtils.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import software.amazon.awssdk.core.SelectedAuthScheme;
2222
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
2323
import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute;
24+
import software.amazon.awssdk.core.useragent.BusinessMetricCollection;
25+
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
2426
import software.amazon.awssdk.endpoints.Endpoint;
2527
import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption;
2628
import software.amazon.awssdk.services.s3.endpoints.internal.KnownS3ExpressEndpointProperty;
@@ -57,4 +59,18 @@ public static boolean useS3ExpressAuthScheme(ExecutionAttributes executionAttrib
5759
}
5860
return false;
5961
}
62+
63+
/**
64+
* Adds S3 Express business metric if applicable for the current operation.
65+
*/
66+
public static void addS3ExpressBusinessMetricIfApplicable(ExecutionAttributes executionAttributes) {
67+
if (useS3Express(executionAttributes) && useS3ExpressAuthScheme(executionAttributes)) {
68+
BusinessMetricCollection businessMetrics =
69+
executionAttributes.getAttribute(SdkInternalExecutionAttribute.BUSINESS_METRICS);
70+
71+
if (businessMetrics != null) {
72+
businessMetrics.addMetric(BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value());
73+
}
74+
}
75+
}
6076
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.awssdk.services.s3.s3express;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import java.util.concurrent.atomic.AtomicReference;
23+
import java.util.regex.Matcher;
24+
import java.util.regex.Pattern;
25+
import org.junit.jupiter.api.BeforeEach;
26+
import org.junit.jupiter.api.Test;
27+
import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider;
28+
import software.amazon.awssdk.core.interceptor.Context;
29+
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
30+
import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
31+
import software.amazon.awssdk.core.sync.RequestBody;
32+
import software.amazon.awssdk.core.sync.ResponseTransformer;
33+
import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId;
34+
import software.amazon.awssdk.http.AbortableInputStream;
35+
import software.amazon.awssdk.http.HttpExecuteResponse;
36+
import software.amazon.awssdk.http.SdkHttpRequest;
37+
import software.amazon.awssdk.http.SdkHttpResponse;
38+
import software.amazon.awssdk.regions.Region;
39+
import software.amazon.awssdk.services.s3.S3Client;
40+
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
41+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
42+
import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient;
43+
import software.amazon.awssdk.utils.StringInputStream;
44+
45+
/**
46+
* Unit test to verify that S3 Express operations include the correct business metric feature ID
47+
* in the User-Agent header.
48+
*/
49+
public class S3ExpressUserAgentTest {
50+
private static final String KEY = "test-feature-id.txt";
51+
private static final String CONTENTS = "test content for feature id validation";
52+
private static final String S3_EXPRESS_BUCKET = "my-test-bucket--use1-az4--x-s3";
53+
private static final String REGULAR_BUCKET = "my-test-bucket-regular";
54+
55+
private final UserAgentCapturingInterceptor userAgentInterceptor = new UserAgentCapturingInterceptor();
56+
private MockSyncHttpClient mockHttpClient;
57+
private S3Client s3Client;
58+
59+
@BeforeEach
60+
void setup() {
61+
// Mock HTTP client
62+
mockHttpClient = new MockSyncHttpClient();
63+
64+
// Mock CreateSession response for S3 Express authentication
65+
String createSessionResponse = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
66+
"<CreateSessionResult>\n" +
67+
" <Credentials>\n" +
68+
" <SessionToken>mock-session-token</SessionToken>\n" +
69+
" <SecretAccessKey>mock-secret-key</SecretAccessKey>\n" +
70+
" <AccessKeyId>mock-access-key</AccessKeyId>\n" +
71+
" <Expiration>2025-12-31T23:59:59Z</Expiration>\n" +
72+
" </Credentials>\n" +
73+
"</CreateSessionResult>";
74+
75+
HttpExecuteResponse createSessionHttpResponse = HttpExecuteResponse.builder()
76+
.response(SdkHttpResponse.builder().statusCode(200).build())
77+
.responseBody(AbortableInputStream.create(new StringInputStream(createSessionResponse)))
78+
.build();
79+
80+
HttpExecuteResponse putResponse = HttpExecuteResponse.builder()
81+
.response(SdkHttpResponse.builder().statusCode(200).build())
82+
.responseBody(AbortableInputStream.create(new StringInputStream("")))
83+
.build();
84+
85+
HttpExecuteResponse getResponse = HttpExecuteResponse.builder()
86+
.response(SdkHttpResponse.builder().statusCode(200).build())
87+
.responseBody(AbortableInputStream.create(new StringInputStream(CONTENTS)))
88+
.build();
89+
90+
mockHttpClient.stubResponses(
91+
createSessionHttpResponse, // First CreateSession call for S3 Express bucket
92+
putResponse, // PUT operation
93+
createSessionHttpResponse, // Second CreateSession call for S3 Express bucket
94+
getResponse, // GET operation
95+
putResponse, // PUT operation for regular bucket
96+
getResponse // GET operation for regular bucket
97+
);
98+
99+
// S3 client with mocked HTTP client
100+
s3Client = S3Client.builder()
101+
.region(Region.US_EAST_1)
102+
.credentialsProvider(AnonymousCredentialsProvider.create())
103+
.httpClient(mockHttpClient)
104+
.overrideConfiguration(o -> o.addExecutionInterceptor(userAgentInterceptor))
105+
.build();
106+
107+
userAgentInterceptor.reset();
108+
}
109+
110+
@Test
111+
void putObject_whenS3ExpressBucket_shouldIncludeS3ExpressFeatureIdInUserAgent() {
112+
PutObjectRequest putRequest = PutObjectRequest.builder()
113+
.bucket(S3_EXPRESS_BUCKET)
114+
.key(KEY)
115+
.build();
116+
117+
s3Client.putObject(putRequest, RequestBody.fromString(CONTENTS));
118+
119+
List<String> capturedUserAgents = userAgentInterceptor.getCapturedUserAgents();
120+
assertThat(capturedUserAgents).hasSize(2); // CreateSession + PutObject calls
121+
122+
// The second User-Agent is from the actual PutObject call
123+
String userAgent = capturedUserAgents.get(1);
124+
assertThat(userAgent).isNotNull();
125+
126+
String expectedFeatureId = BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value();
127+
String businessMetrics = extractBusinessMetrics(userAgent);
128+
129+
assertThat(businessMetrics).contains(expectedFeatureId);
130+
assertThat(userAgent).contains(" m/" + businessMetrics);
131+
}
132+
133+
@Test
134+
void getObject_whenS3ExpressBucket_shouldIncludeS3ExpressFeatureIdInUserAgent() {
135+
GetObjectRequest getRequest = GetObjectRequest.builder()
136+
.bucket(S3_EXPRESS_BUCKET)
137+
.key(KEY)
138+
.build();
139+
140+
s3Client.getObject(getRequest, ResponseTransformer.toBytes());
141+
142+
List<String> capturedUserAgents = userAgentInterceptor.getCapturedUserAgents();
143+
assertThat(capturedUserAgents).hasSize(2);
144+
145+
String userAgent = capturedUserAgents.get(1);
146+
assertThat(userAgent).isNotNull();
147+
148+
String expectedFeatureId = BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value();
149+
String businessMetrics = extractBusinessMetrics(userAgent);
150+
151+
assertThat(businessMetrics).isNotNull();
152+
assertThat(businessMetrics).contains(expectedFeatureId);
153+
assertThat(userAgent).contains(" m/" + businessMetrics);
154+
}
155+
156+
@Test
157+
void putObject_whenRegularS3Bucket_shouldNotIncludeS3ExpressFeatureIdInUserAgent() {
158+
PutObjectRequest putRequest = PutObjectRequest.builder()
159+
.bucket(REGULAR_BUCKET)
160+
.key(KEY)
161+
.build();
162+
163+
s3Client.putObject(putRequest, RequestBody.fromString(CONTENTS));
164+
165+
List<String> capturedUserAgents = userAgentInterceptor.getCapturedUserAgents();
166+
assertThat(capturedUserAgents).hasSize(1);
167+
168+
String userAgent = capturedUserAgents.get(0);
169+
assertThat(userAgent).isNotNull();
170+
171+
String s3ExpressFeatureId = BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value();
172+
173+
String businessMetrics = extractBusinessMetrics(userAgent);
174+
if (businessMetrics != null) {
175+
assertThat(businessMetrics).doesNotContain(s3ExpressFeatureId);
176+
}
177+
178+
}
179+
180+
@Test
181+
void getObject_whenRegularS3Bucket_shouldNotIncludeS3ExpressFeatureIdInUserAgent() {
182+
GetObjectRequest getRequest = GetObjectRequest.builder()
183+
.bucket(REGULAR_BUCKET)
184+
.key(KEY)
185+
.build();
186+
187+
s3Client.getObject(getRequest, ResponseTransformer.toBytes());
188+
189+
List<String> capturedUserAgents = userAgentInterceptor.getCapturedUserAgents();
190+
assertThat(capturedUserAgents).hasSize(1);
191+
192+
String userAgent = capturedUserAgents.get(0);
193+
assertThat(userAgent).isNotNull();
194+
195+
String s3ExpressFeatureId = BusinessMetricFeatureId.S3_EXPRESS_BUCKET.value();
196+
197+
String businessMetrics = extractBusinessMetrics(userAgent);
198+
if (businessMetrics != null) {
199+
assertThat(businessMetrics).doesNotContain(s3ExpressFeatureId);
200+
}
201+
202+
}
203+
204+
/**
205+
* Extracts the business metrics section from a User-Agent string.
206+
* Business metrics appear as "m/D,J" where D and J are feature IDs.
207+
*/
208+
private String extractBusinessMetrics(String userAgent) {
209+
if (userAgent == null) {
210+
return null;
211+
}
212+
213+
// Pattern to match business metrics: " m/feature1,feature2"
214+
Pattern pattern = Pattern.compile(" m/([A-Za-z0-9+\\-,]+)");
215+
Matcher matcher = pattern.matcher(userAgent);
216+
217+
if (matcher.find()) {
218+
return matcher.group(1);
219+
}
220+
221+
return null;
222+
}
223+
224+
/**
225+
* Interceptor to capture User-Agent headers from HTTP requests
226+
*/
227+
private static class UserAgentCapturingInterceptor implements ExecutionInterceptor {
228+
private final List<String> capturedUserAgents = new ArrayList<>();
229+
private final AtomicReference<String> lastUserAgent = new AtomicReference<>();
230+
231+
@Override
232+
public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) {
233+
SdkHttpRequest httpRequest = context.httpRequest();
234+
List<String> userAgentHeaders = httpRequest.headers().get("User-Agent");
235+
236+
if (userAgentHeaders != null && !userAgentHeaders.isEmpty()) {
237+
String userAgent = userAgentHeaders.get(0);
238+
capturedUserAgents.add(userAgent);
239+
lastUserAgent.set(userAgent);
240+
}
241+
}
242+
243+
public List<String> getCapturedUserAgents() {
244+
return new ArrayList<>(capturedUserAgents);
245+
}
246+
247+
public String getLastUserAgent() {
248+
return lastUserAgent.get();
249+
}
250+
251+
public void reset() {
252+
capturedUserAgents.clear();
253+
lastUserAgent.set(null);
254+
}
255+
}
256+
}

0 commit comments

Comments
 (0)