Skip to content

Commit e500830

Browse files
authoredJan 24, 2024
chore(middleware-sdk-s3): add string fallback for S3#Expires field (#5715)
1 parent 1452cd4 commit e500830

File tree

10 files changed

+293
-7
lines changed

10 files changed

+293
-7
lines changed
 

‎clients/client-s3/src/commands/GetObjectCommand.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// smithy-typescript generated code
22
import { getFlexibleChecksumsPlugin } from "@aws-sdk/middleware-flexible-checksums";
3+
import { getS3ExpiresMiddlewarePlugin } from "@aws-sdk/middleware-sdk-s3";
34
import { getSsecPlugin } from "@aws-sdk/middleware-ssec";
45
import { getEndpointPlugin } from "@smithy/middleware-endpoint";
56
import { getSerdePlugin } from "@smithy/middleware-serde";
@@ -240,6 +241,7 @@ export interface GetObjectCommandOutput extends Omit<GetObjectOutput, "Body">, _
240241
* // ContentRange: "STRING_VALUE",
241242
* // ContentType: "STRING_VALUE",
242243
* // Expires: new Date("TIMESTAMP"),
244+
* // ExpiresString: "STRING_VALUE",
243245
* // WebsiteRedirectLocation: "STRING_VALUE",
244246
* // ServerSideEncryption: "AES256" || "aws:kms" || "aws:kms:dsse",
245247
* // Metadata: { // Metadata
@@ -351,6 +353,7 @@ export class GetObjectCommand extends $Command
351353
getSerdePlugin(config, this.serialize, this.deserialize),
352354
getEndpointPlugin(config, Command.getEndpointParameterInstructions()),
353355
getSsecPlugin(config),
356+
getS3ExpiresMiddlewarePlugin(config),
354357
getFlexibleChecksumsPlugin(config, {
355358
input: this.input,
356359
requestChecksumRequired: false,

‎clients/client-s3/src/commands/HeadObjectCommand.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// smithy-typescript generated code
2+
import { getS3ExpiresMiddlewarePlugin } from "@aws-sdk/middleware-sdk-s3";
23
import { getSsecPlugin } from "@aws-sdk/middleware-ssec";
34
import { getEndpointPlugin } from "@smithy/middleware-endpoint";
45
import { getSerdePlugin } from "@smithy/middleware-serde";
@@ -215,6 +216,7 @@ export interface HeadObjectCommandOutput extends HeadObjectOutput, __MetadataBea
215216
* // ContentLanguage: "STRING_VALUE",
216217
* // ContentType: "STRING_VALUE",
217218
* // Expires: new Date("TIMESTAMP"),
219+
* // ExpiresString: "STRING_VALUE",
218220
* // WebsiteRedirectLocation: "STRING_VALUE",
219221
* // ServerSideEncryption: "AES256" || "aws:kms" || "aws:kms:dsse",
220222
* // Metadata: { // Metadata
@@ -289,6 +291,7 @@ export class HeadObjectCommand extends $Command
289291
getSerdePlugin(config, this.serialize, this.deserialize),
290292
getEndpointPlugin(config, Command.getEndpointParameterInstructions()),
291293
getSsecPlugin(config),
294+
getS3ExpiresMiddlewarePlugin(config),
292295
];
293296
})
294297
.s("AmazonS3", "HeadObject", {})

‎clients/client-s3/src/models/models_0.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// smithy-typescript generated code
22
import { ExceptionOptionType as __ExceptionOptionType, SENSITIVE_STRING } from "@smithy/smithy-client";
3-
43
import { StreamingBlobTypes } from "@smithy/types";
54

65
import { S3ServiceException as __BaseException } from "./S3ServiceException";
@@ -9037,10 +9036,18 @@ export interface GetObjectOutput {
90379036

90389037
/**
90399038
* @public
9040-
* <p>The date and time at which the object is no longer cacheable.</p>
9039+
* @deprecated
9040+
*
9041+
* Deprecated in favor of ExpiresString.
90419042
*/
90429043
Expires?: Date;
90439044

9045+
/**
9046+
* @public
9047+
* <p>The date and time at which the object is no longer cacheable.</p>
9048+
*/
9049+
ExpiresString?: string;
9050+
90449051
/**
90459052
* @public
90469053
* <p>If the bucket is configured as a website, redirects requests for this object to another
@@ -10772,10 +10779,18 @@ export interface HeadObjectOutput {
1077210779

1077310780
/**
1077410781
* @public
10775-
* <p>The date and time at which the object is no longer cacheable.</p>
10782+
* @deprecated
10783+
*
10784+
* Deprecated in favor of ExpiresString.
1077610785
*/
1077710786
Expires?: Date;
1077810787

10788+
/**
10789+
* @public
10790+
* <p>The date and time at which the object is no longer cacheable.</p>
10791+
*/
10792+
ExpiresString?: string;
10793+
1077910794
/**
1078010795
* @public
1078110796
* <p>If the bucket is configured as a website, redirects requests for this object to another

‎clients/client-s3/src/models/models_1.ts

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// smithy-typescript generated code
22
import { ExceptionOptionType as __ExceptionOptionType, SENSITIVE_STRING } from "@smithy/smithy-client";
3-
43
import { StreamingBlobTypes } from "@smithy/types";
54

65
import {
@@ -28,7 +27,6 @@ import {
2827
StorageClass,
2928
Tag,
3029
} from "./models_0";
31-
3230
import { S3ServiceException as __BaseException } from "./S3ServiceException";
3331

3432
/**

‎clients/client-s3/src/protocols/Aws_restXml.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -4950,6 +4950,7 @@ export const de_GetObjectCommand = async (
49504950
[_CR]: [, output.headers[_cr]],
49514951
[_CT]: [, output.headers[_ct]],
49524952
[_E]: [() => void 0 !== output.headers[_e], () => __expectNonNull(__parseRfc7231DateTime(output.headers[_e]))],
4953+
[_ES]: [, output.headers[_ex]],
49534954
[_WRL]: [, output.headers[_xawrl]],
49544955
[_SSE]: [, output.headers[_xasse]],
49554956
[_SSECA]: [, output.headers[_xasseca]],
@@ -5440,6 +5441,7 @@ export const de_HeadObjectCommand = async (
54405441
[_CL]: [, output.headers[_cl]],
54415442
[_CT]: [, output.headers[_ct]],
54425443
[_E]: [() => void 0 !== output.headers[_e], () => __expectNonNull(__parseRfc7231DateTime(output.headers[_e]))],
5444+
[_ES]: [, output.headers[_ex]],
54435445
[_WRL]: [, output.headers[_xawrl]],
54445446
[_SSE]: [, output.headers[_xasse]],
54455447
[_SSECA]: [, output.headers[_xasseca]],
@@ -8359,7 +8361,7 @@ const se_LifecycleRule = (input: LifecycleRule, context: __SerdeContext): any =>
83598361
bn.c(se_LifecycleRuleFilter(input[_F], context).n(_F));
83608362
}
83618363
if (input[_S] != null) {
8362-
bn.c(__XmlNode.of(_ES, input[_S]).n(_S));
8364+
bn.c(__XmlNode.of(_ESx, input[_S]).n(_S));
83638365
}
83648366
bn.l(input, "Transitions", "Transition", () => se_TransitionList(input[_Tr]!, context));
83658367
bn.l(input, "NoncurrentVersionTransitions", "NoncurrentVersionTransition", () =>
@@ -11772,8 +11774,9 @@ const _EODM = "ExpiredObjectDeleteMarker";
1177211774
const _EOR = "ExistingObjectReplication";
1177311775
const _EORS = "ExistingObjectReplicationStatus";
1177411776
const _ERP = "EnableRequestProgress";
11775-
const _ES = "ExpirationStatus";
11777+
const _ES = "ExpiresString";
1177611778
const _ESBO = "ExpectedSourceBucketOwner";
11779+
const _ESx = "ExpirationStatus";
1177711780
const _ET = "EncodingType";
1177811781
const _ETa = "ETag";
1177911782
const _ETn = "EncryptionType";
@@ -12116,6 +12119,7 @@ const _e = "expires";
1211612119
const _en = "encryption";
1211712120
const _et = "encoding-type";
1211812121
const _eta = "etag";
12122+
const _ex = "expiresstring";
1211912123
const _fo = "fetch-owner";
1212012124
const _i = "id";
1212112125
const _im = "if-match";

‎codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AddS3Config.java

+15
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ && containsInputMembers(m, o, BUCKET_ENDPOINT_INPUT_KEYS))
269269
HAS_MIDDLEWARE)
270270
.servicePredicate((m, s) -> isS3(s))
271271
.build(),
272+
RuntimeClientPlugin.builder()
273+
.withConventions(AwsDependency.S3_MIDDLEWARE.dependency, "S3ExpiresMiddleware",
274+
HAS_MIDDLEWARE)
275+
.operationPredicate((m, s, o) -> containsExpiresOutput(m, o))
276+
.build(),
272277
RuntimeClientPlugin.builder()
273278
.withConventions(AwsDependency.S3_MIDDLEWARE.dependency, "S3Express",
274279
HAS_MIDDLEWARE)
@@ -288,6 +293,16 @@ private static boolean containsInputMembers(
288293
.isPresent();
289294
}
290295

296+
private static boolean containsExpiresOutput(
297+
Model model,
298+
OperationShape operationShape
299+
) {
300+
OperationIndex operationIndex = OperationIndex.of(model);
301+
return operationIndex.getOutput(operationShape)
302+
.filter(input -> input.getMemberNames().stream().anyMatch("Expires"::equals))
303+
.isPresent();
304+
}
305+
291306
private static boolean isS3(Shape serviceShape) {
292307
return serviceShape.getTrait(ServiceTrait.class).map(ServiceTrait::getSdkId).orElse("").equals("S3");
293308
}

‎packages/middleware-sdk-s3/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./check-content-length-header";
22
export * from "./region-redirect-endpoint-middleware";
33
export * from "./region-redirect-middleware";
4+
export * from "./s3-expires-middleware";
45
export * from "./s3-express/index";
56
export * from "./s3Configuration";
67
export * from "./throw-200-exceptions";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { S3 } from "@aws-sdk/client-s3";
2+
import { GetCallerIdentityCommandOutput, STS } from "@aws-sdk/client-sts";
3+
4+
jest.setTimeout(25000);
5+
6+
describe("S3 Expires e2e test", () => {
7+
const s3 = new S3({
8+
region: "us-west-2",
9+
logger: {
10+
trace() {},
11+
debug() {},
12+
info() {},
13+
warn: jest.fn(),
14+
error() {},
15+
},
16+
});
17+
const stsClient = new STS({ region: "us-west-2" });
18+
19+
let callerID = null as unknown as GetCallerIdentityCommandOutput;
20+
let Bucket: string;
21+
22+
// random element limited to 2 letters to avoid concurrent IO, and
23+
// to limit bucket count to 676 if there is failure to delete them.
24+
const alphabet = "abcdefghijklmnopqrstuvwxyz";
25+
const randId = alphabet[(Math.random() * alphabet.length) | 0] + alphabet[(Math.random() * alphabet.length) | 0];
26+
27+
beforeAll(async () => {
28+
callerID = await stsClient.getCallerIdentity({});
29+
Bucket = `${callerID.Account}-${randId}-s3-expires`;
30+
await s3.createBucket({
31+
Bucket,
32+
});
33+
});
34+
35+
afterAll(async () => {
36+
await deleteBucket(s3, Bucket);
37+
});
38+
39+
const staticDate = new Date(0);
40+
const dateString = "Thu, 01 Jan 1970 00:00:00 GMT";
41+
42+
it("should parse Expires from response if it is valid date-time, and include ExpiresString", async () => {
43+
await s3.putObject({
44+
Bucket,
45+
Key: "good-expires",
46+
Expires: staticDate,
47+
Body: "good-expires",
48+
});
49+
50+
const get = await s3.getObject({
51+
Bucket,
52+
Key: "good-expires",
53+
});
54+
await get.Body?.transformToByteArray(); // drain stream.
55+
56+
expect(get.Expires?.getTime()).toEqual(staticDate.getTime());
57+
expect(get.ExpiresString).toEqual(dateString);
58+
});
59+
60+
it("should fail with a non-blocking warning if Expires is not a valid date-time, and include the raw string in ExpiresString", async () => {
61+
await s3.putObject({
62+
Bucket,
63+
Key: "bad-expires",
64+
Expires: new Date("invalid date"),
65+
Body: "bad-expires",
66+
});
67+
68+
const get = await s3.getObject({
69+
Bucket,
70+
Key: "bad-expires",
71+
});
72+
await get.Body?.transformToByteArray(); // drain stream.
73+
74+
expect(get.Expires).toBeUndefined();
75+
expect(s3.config.logger.warn).toHaveBeenCalledWith(
76+
`AWS SDK Warning for S3Client::GetObjectCommand response parsing (undefined, NaN undefined NaN NaN:NaN:NaN GMT): TypeError: Invalid RFC-7231 date-time value`
77+
);
78+
expect(get.ExpiresString).toEqual("undefined, NaN undefined NaN NaN:NaN:NaN GMT");
79+
});
80+
});
81+
82+
async function deleteBucket(s3: S3, bucketName: string) {
83+
const Bucket = bucketName;
84+
85+
try {
86+
await s3.headBucket({
87+
Bucket,
88+
});
89+
} catch (e) {
90+
return;
91+
}
92+
93+
const list = await s3
94+
.listObjects({
95+
Bucket,
96+
})
97+
.catch((e) => {
98+
if (!String(e).includes("NoSuchBucket")) {
99+
throw e;
100+
}
101+
return {
102+
Contents: [],
103+
};
104+
});
105+
106+
const promises = [] as any[];
107+
for (const key of list.Contents ?? []) {
108+
promises.push(
109+
s3.deleteObject({
110+
Bucket,
111+
Key: key.Key,
112+
})
113+
);
114+
}
115+
await Promise.all(promises);
116+
117+
try {
118+
return await s3.deleteBucket({
119+
Bucket,
120+
});
121+
} catch (e) {
122+
if (!String(e).includes("NoSuchBucket")) {
123+
throw e;
124+
}
125+
}
126+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { HttpResponse } from "@smithy/protocol-http";
2+
import { parseRfc7231DateTime } from "@smithy/smithy-client";
3+
import {
4+
DeserializeHandler,
5+
DeserializeHandlerArguments,
6+
DeserializeHandlerOutput,
7+
DeserializeMiddleware,
8+
HandlerExecutionContext,
9+
MetadataBearer,
10+
Pluggable,
11+
RelativeMiddlewareOptions,
12+
} from "@smithy/types";
13+
14+
/**
15+
* @internal
16+
*/
17+
interface PreviouslyResolved {}
18+
19+
/**
20+
* @internal
21+
*
22+
* From the S3 Expires compatibility spec.
23+
* A model transform will ensure S3#Expires remains a timestamp shape, though
24+
* it is deprecated.
25+
* If a particular object has a non-date string set as the Expires value,
26+
* the SDK will have the raw string as "ExpiresString" on the response.
27+
*
28+
*/
29+
export const s3ExpiresMiddleware = (config: PreviouslyResolved): DeserializeMiddleware<any, any> => {
30+
return <Output extends MetadataBearer>(
31+
next: DeserializeHandler<any, Output>,
32+
context: HandlerExecutionContext
33+
): DeserializeHandler<any, Output> =>
34+
async (args: DeserializeHandlerArguments<any>): Promise<DeserializeHandlerOutput<Output>> => {
35+
const result = await next(args);
36+
const { response } = result;
37+
if (HttpResponse.isInstance(response)) {
38+
if (response.headers.expires) {
39+
response.headers.expiresstring = response.headers.expires;
40+
try {
41+
parseRfc7231DateTime(response.headers.expires);
42+
} catch (e) {
43+
context.logger?.warn(
44+
`AWS SDK Warning for ${context.clientName}::${context.commandName} response parsing (${response.headers.expires}): ${e}`
45+
);
46+
delete response.headers.expires;
47+
}
48+
}
49+
}
50+
return result;
51+
};
52+
};
53+
54+
/**
55+
* @internal
56+
*/
57+
export const s3ExpiresMiddlewareOptions: RelativeMiddlewareOptions = {
58+
tags: ["S3"],
59+
name: "s3ExpiresMiddleware",
60+
override: true,
61+
relation: "after",
62+
toMiddleware: "deserializerMiddleware",
63+
};
64+
65+
/**
66+
* @internal
67+
*/
68+
export const getS3ExpiresMiddlewarePlugin = (clientConfig: PreviouslyResolved): Pluggable<any, any> => ({
69+
applyToStack: (clientStack) => {
70+
clientStack.addRelativeTo(s3ExpiresMiddleware(clientConfig), s3ExpiresMiddlewareOptions);
71+
},
72+
});

‎scripts/generate-clients/s3-hack.js

+49
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const s3ModelObject = require(s3ModelLocation);
1010

1111
/**
1212
* Activates a hack for S3-express Smithy suppression.
13+
* And another one for S3 Expires.
14+
*
1315
* @returns a function that undoes the hack.
1416
*/
1517
module.exports = function () {
@@ -21,6 +23,53 @@ module.exports = function () {
2123
namespace: "com.amazonaws.s3",
2224
});
2325

26+
const expiresShape = s3ModelObject.shapes["com.amazonaws.s3#Expires"];
27+
if (expiresShape) {
28+
// enforce that Expires retains type timestamp.
29+
expiresShape.type = "timestamp";
30+
31+
// add the ExpiresString string shape.
32+
const newShapes = {};
33+
for (const [shapeId, shape] of Object.entries(s3ModelObject.shapes)) {
34+
newShapes[shapeId] = shape;
35+
if (shapeId === "com.amazonaws.s3#Expires") {
36+
newShapes["com.amazonaws.s3#ExpiresString"] = {
37+
type: "string",
38+
};
39+
}
40+
}
41+
s3ModelObject.shapes = newShapes;
42+
43+
// add ExpiresString alongside output shapes containing Expires.
44+
for (const [shapeId, shape] of Object.entries(s3ModelObject.shapes)) {
45+
if (shape?.traits?.["smithy.api#output"]) {
46+
const newMembers = {};
47+
for (const [memberName, member] of Object.entries(shape.members)) {
48+
newMembers[memberName] = member;
49+
if (member.target === "com.amazonaws.s3#Expires") {
50+
const existingDoc = member.traits["smithy.api#documentation"];
51+
if (!member.traits) {
52+
member.traits = {};
53+
}
54+
55+
newMembers.ExpiresString = {
56+
target: "com.amazonaws.s3#ExpiresString",
57+
traits: {
58+
...member.traits,
59+
"smithy.api#httpHeader": "ExpiresString",
60+
"smithy.api#documentation": existingDoc,
61+
},
62+
};
63+
64+
member.traits["smithy.api#deprecated"] = {};
65+
member.traits["smithy.api#documentation"] = "Deprecated in favor of ExpiresString.";
66+
}
67+
}
68+
shape.members = newMembers;
69+
}
70+
}
71+
}
72+
2473
fs.writeFileSync(s3ModelLocation, JSON.stringify(s3ModelObject, null, 2));
2574

2675
return () => {

0 commit comments

Comments
 (0)
Please sign in to comment.