Skip to content

Commit c6c1626

Browse files
authored
s3: handle unrecognized values for Expires in responses (#2653)
1 parent 8209abb commit c6c1626

File tree

7 files changed

+253
-6
lines changed

7 files changed

+253
-6
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"id": "38487867-7f9a-4885-825e-6a4cc2b1012f",
3+
"type": "bugfix",
4+
"description": "Prevent parsing failures for nonstandard `Expires` values in responses. If the SDK cannot parse the value set in the response header for this field it will now be returned as `nil`. A new field, `ExpiresString`, has been added that will retain the unparsed value from the response (regardless of whether it came back in a format recognized by the SDK).",
5+
"modules": [
6+
"service/s3"
7+
]
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright 2024 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.smithy.aws.go.codegen.customization.s3;
17+
18+
import static software.amazon.smithy.aws.go.codegen.customization.S3ModelUtils.isServiceS3;
19+
import static software.amazon.smithy.go.codegen.GoWriter.goTemplate;
20+
import static software.amazon.smithy.go.codegen.SymbolUtils.buildPackageSymbol;
21+
22+
import java.util.List;
23+
import software.amazon.smithy.codegen.core.SymbolProvider;
24+
import software.amazon.smithy.go.codegen.GoDelegator;
25+
import software.amazon.smithy.go.codegen.GoSettings;
26+
import software.amazon.smithy.go.codegen.GoWriter;
27+
import software.amazon.smithy.go.codegen.SmithyGoDependency;
28+
import software.amazon.smithy.go.codegen.integration.GoIntegration;
29+
import software.amazon.smithy.go.codegen.integration.RuntimeClientPlugin;
30+
import software.amazon.smithy.model.Model;
31+
import software.amazon.smithy.model.shapes.MemberShape;
32+
import software.amazon.smithy.model.shapes.Shape;
33+
import software.amazon.smithy.model.shapes.ShapeId;
34+
import software.amazon.smithy.model.shapes.StringShape;
35+
import software.amazon.smithy.model.traits.DeprecatedTrait;
36+
import software.amazon.smithy.model.traits.DocumentationTrait;
37+
import software.amazon.smithy.model.traits.HttpHeaderTrait;
38+
import software.amazon.smithy.model.traits.OutputTrait;
39+
import software.amazon.smithy.model.transform.ModelTransformer;
40+
import software.amazon.smithy.utils.MapUtils;
41+
42+
/**
43+
* Restrictions around timestamp formatting for the 'Expires' value in some S3 responses has never been standardized and
44+
* thus many non-conforming values for the field (unsupported formats, arbitrary strings, etc.) exist in the wild. This
45+
* customization makes the response parsing forgiving for this field in responses and adds an ExpiresString field that
46+
* contains the unparsed value.
47+
*/
48+
public class S3ExpiresShapeCustomization implements GoIntegration {
49+
private static final ShapeId S3_EXPIRES = ShapeId.from("com.amazonaws.s3#Expires");
50+
private static final ShapeId S3_EXPIRES_STRING = ShapeId.from("com.amazonaws.s3#ExpiresString");
51+
private static final String DESERIALIZE_S3_EXPIRES = "deserializeS3Expires";
52+
53+
@Override
54+
public List<RuntimeClientPlugin> getClientPlugins() {
55+
return List.of(RuntimeClientPlugin.builder()
56+
.addShapeDeserializer(S3_EXPIRES, buildPackageSymbol(DESERIALIZE_S3_EXPIRES))
57+
.build());
58+
}
59+
60+
@Override
61+
public Model preprocessModel(Model model, GoSettings settings) {
62+
if (!isServiceS3(model, settings.getService(model))) {
63+
return model;
64+
}
65+
66+
var withExpiresString = model.toBuilder()
67+
.addShape(StringShape.builder()
68+
.id(S3_EXPIRES_STRING)
69+
.build())
70+
.build();
71+
return ModelTransformer.create().mapShapes(withExpiresString, this::addExpiresString);
72+
}
73+
74+
@Override
75+
public void writeAdditionalFiles(GoSettings settings, Model model, SymbolProvider symbolProvider, GoDelegator goDelegator) {
76+
goDelegator.useFileWriter("deserializers.go", settings.getModuleName(), deserializeS3Expires());
77+
}
78+
79+
private Shape addExpiresString(Shape shape) {
80+
if (!shape.hasTrait(OutputTrait.class)) {
81+
return shape;
82+
}
83+
84+
var expires = shape.getMember(S3_EXPIRES.getName());
85+
if (expires.isEmpty()) {
86+
return shape;
87+
}
88+
89+
if (!expires.get().getTarget().equals(S3_EXPIRES)) {
90+
return shape;
91+
}
92+
93+
var deprecated = DeprecatedTrait.builder()
94+
.message("This field is handled inconsistently across AWS SDKs. Prefer using the ExpiresString field " +
95+
"which contains the unparsed value from the service response.")
96+
.build();
97+
var stringDocs = new DocumentationTrait("The unparsed value of the Expires field from the service " +
98+
"response. Prefer use of this value over the normal Expires response field where possible.");
99+
return Shape.shapeToBuilder(shape)
100+
.addMember(expires.get().toBuilder()
101+
.addTrait(deprecated)
102+
.build())
103+
.addMember(MemberShape.builder()
104+
.id(shape.getId().withMember(S3_EXPIRES_STRING.getName()))
105+
.target(S3_EXPIRES_STRING)
106+
.addTrait(expires.get().expectTrait(HttpHeaderTrait.class)) // copies header name
107+
.addTrait(stringDocs)
108+
.build())
109+
.build();
110+
}
111+
112+
private GoWriter.Writable deserializeS3Expires() {
113+
return goTemplate("""
114+
func $name:L(v string) ($time:P, error) {
115+
t, err := $parseHTTPDate:T(v)
116+
if err != nil {
117+
return nil, nil
118+
}
119+
return &t, nil
120+
}
121+
""",
122+
MapUtils.of(
123+
"name", DESERIALIZE_S3_EXPIRES,
124+
"time", SmithyGoDependency.TIME.struct("Time"),
125+
"parseHTTPDate", SmithyGoDependency.SMITHY_TIME.func("ParseHTTPDate")
126+
));
127+
}
128+
}

codegen/smithy-aws-go-codegen/src/main/resources/META-INF/services/software.amazon.smithy.go.codegen.integration.GoIntegration

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,4 @@ software.amazon.smithy.aws.go.codegen.customization.auth.GlobalAnonymousOption
7575
software.amazon.smithy.aws.go.codegen.customization.CloudFrontKVSSigV4a
7676
software.amazon.smithy.aws.go.codegen.customization.BackfillProtocolTestServiceTrait
7777
software.amazon.smithy.go.codegen.integration.MiddlewareStackSnapshotTests
78+
software.amazon.smithy.aws.go.codegen.customization.s3.S3ExpiresShapeCustomization

service/s3/api_op_GetObject.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

service/s3/api_op_HeadObject.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

service/s3/deserializers.go

Lines changed: 25 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

service/s3/expires_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package s3
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"testing"
7+
"time"
8+
9+
"github.com/aws/aws-sdk-go-v2/aws"
10+
)
11+
12+
type mockHeadObject struct {
13+
expires string
14+
}
15+
16+
func (m *mockHeadObject) Do(r *http.Request) (*http.Response, error) {
17+
return &http.Response{
18+
StatusCode: 200,
19+
Header: http.Header{
20+
"Expires": {m.expires},
21+
},
22+
Body: http.NoBody,
23+
}, nil
24+
}
25+
26+
func TestInvalidExpires(t *testing.T) {
27+
expires := "2023-11-01"
28+
svc := New(Options{
29+
HTTPClient: &mockHeadObject{expires},
30+
Region: "us-east-1",
31+
})
32+
33+
out, err := svc.HeadObject(context.Background(), &HeadObjectInput{
34+
Bucket: aws.String("bucket"),
35+
Key: aws.String("key"),
36+
})
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
41+
if out.Expires != nil {
42+
t.Errorf("out.Expires should be nil, is %s", *out.Expires)
43+
}
44+
if aws.ToString(out.ExpiresString) != expires {
45+
t.Errorf("out.ExpiresString should be %s, is %s", expires, *out.ExpiresString)
46+
}
47+
}
48+
49+
func TestValidExpires(t *testing.T) {
50+
exs := "Mon, 02 Jan 2006 15:04:05 GMT"
51+
ext, err := time.Parse(exs, exs)
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
56+
svc := New(Options{
57+
HTTPClient: &mockHeadObject{exs},
58+
Region: "us-east-1",
59+
})
60+
61+
out, err := svc.HeadObject(context.Background(), &HeadObjectInput{
62+
Bucket: aws.String("bucket"),
63+
Key: aws.String("key"),
64+
})
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
69+
if aws.ToTime(out.Expires) != ext {
70+
t.Errorf("out.Expires should be %s, is %s", ext, *out.Expires)
71+
}
72+
if aws.ToString(out.ExpiresString) != exs {
73+
t.Errorf("out.ExpiresString should be %s, is %s", exs, *out.ExpiresString)
74+
}
75+
}

0 commit comments

Comments
 (0)