Skip to content

Commit

Permalink
chore(sdk): optimizing bucket.exists() for aws targets (#3654)
Browse files Browse the repository at this point in the history
I was randomly thinking that using `ListObjectsV2Command` may not be the most efficient way to check if an object exists in a S3 bucket, since you are asking AWS to look for all the objects that have a specific prefix even though the API returns only one object when filtering with `MaxKeys: 1`. I thought that probably just checking for the metadata of the single object using `HeadObjectCommand` would be enough to determine whether the object exists.

*By submitting this pull request, I confirm that my contribution is made under the terms of the [Wing Cloud Contribution License](https://github.com/winglang/wing/blob/main/CONTRIBUTION_LICENSE.md)*.
  • Loading branch information
garysassano authored Aug 2, 2023
1 parent 03351fc commit a561686
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 52 deletions.
14 changes: 8 additions & 6 deletions libs/wingsdk/src/shared-aws/bucket.inflight.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Readable } from "stream";
import * as consumers from "stream/consumers";
import {
HeadObjectCommand,
HeadObjectCommandOutput,
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
Expand All @@ -27,13 +29,13 @@ export class BucketClient implements IBucketClient {
* @param key Key of the object
*/
public async exists(key: string): Promise<boolean> {
const command = new ListObjectsV2Command({
const command = new HeadObjectCommand({
Bucket: this.bucketName,
Prefix: key,
MaxKeys: 1,
Key: key,
});
const resp: ListObjectsV2CommandOutput = await this.s3Client.send(command);
return !!resp.Contents && resp.Contents.length > 0;

const resp: HeadObjectCommandOutput = await this.s3Client.send(command);
return !!resp?.ContentLength;
}

/**
Expand Down Expand Up @@ -235,7 +237,7 @@ export class BucketClient implements IBucketClient {

if (!(await this.exists(key))) {
throw new Error(
`Cannot provide public url for an non-existent key (key=${key})`
`Cannot provide public url for a non-existent key (key=${key})`
);
}

Expand Down
3 changes: 2 additions & 1 deletion libs/wingsdk/src/shared-aws/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ export function calculateBucketPermissions(
ops.includes(cloud.BucketInflightMethods.LIST) ||
ops.includes(cloud.BucketInflightMethods.TRY_GET) ||
ops.includes(cloud.BucketInflightMethods.TRY_GET_JSON) ||
ops.includes(cloud.BucketInflightMethods.PUBLIC_URL)
ops.includes(cloud.BucketInflightMethods.PUBLIC_URL) ||
ops.includes(cloud.BucketInflightMethods.EXISTS)
) {
actions.push(...["s3:GetObject*", "s3:GetBucket*"]);
}
Expand Down
117 changes: 74 additions & 43 deletions libs/wingsdk/test/shared-aws/bucket.inflight.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Readable } from "stream";
import {
HeadObjectCommand,
DeleteObjectCommand,
GetBucketLocationCommand,
GetObjectCommand,
Expand Down Expand Up @@ -159,9 +160,15 @@ test("delete object from the bucket with mustExist option", async () => {
s3Mock
.on(DeleteObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.resolves({});
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [{ Key: KEY }] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({
AcceptRanges: "bytes",
ContentLength: 3191,
ContentType: "image/jpeg",
ETag: "6805f2cfc46c0f04559748bb039d69ae",
LastModified: new Date("Thu, 15 Dec 2016 01:19:41 GMT"),
Metadata: {},
VersionId: "null",
});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -179,9 +186,7 @@ test("delete non-existent object from the bucket with mustExist option", async (
s3Mock
.on(DeleteObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.resolves({});
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand Down Expand Up @@ -237,9 +242,7 @@ test("Given a public bucket when reaching to a non existent key, public url it s
s3Mock
.on(GetBucketLocationCommand, { Bucket: BUCKET_NAME })
.resolves({ LocationConstraint: "us-east-2" });
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({});

//WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -250,11 +253,11 @@ test("Given a public bucket when reaching to a non existent key, public url it s
}
// THEN
expect(error?.message).toBe(
"Cannot provide public url for an non-existent key (key=KEY)"
"Cannot provide public url for a non-existent key (key=KEY)"
);
});

test("Given a public bucket, when giving one of its keys, we should get it's public url", async () => {
test("Given a public bucket, when giving one of its keys, we should get its public url", async () => {
// GIVEN
const BUCKET_NAME = "BUCKET_NAME";
const KEY = "KEY";
Expand All @@ -271,9 +274,15 @@ test("Given a public bucket, when giving one of its keys, we should get it's pub
s3Mock
.on(GetBucketLocationCommand, { Bucket: BUCKET_NAME })
.resolves({ LocationConstraint: REGION });
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [{}] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({
AcceptRanges: "bytes",
ContentLength: 3191,
ContentType: "image/jpeg",
ETag: "6805f2cfc46c0f04559748bb039d69ae",
LastModified: new Date("Thu, 15 Dec 2016 01:19:41 GMT"),
Metadata: {},
VersionId: "null",
});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -289,10 +298,16 @@ test("check that an object exists in the bucket", async () => {
// GIVEN
const BUCKET_NAME = "BUCKET_NAME";
const KEY = "KEY";
const VALUE = "VALUE";
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [{ Key: KEY }] });

s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({
AcceptRanges: "bytes",
ContentLength: 3191,
ContentType: "image/jpeg",
ETag: "6805f2cfc46c0f04559748bb039d69ae",
LastModified: new Date("Thu, 15 Dec 2016 01:19:41 GMT"),
Metadata: {},
VersionId: "null",
});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -306,10 +321,8 @@ test("check that an object doesn't exist in the bucket", async () => {
// GIVEN
const BUCKET_NAME = "BUCKET_NAME";
const KEY = "KEY";
const VALUE = "VALUE";
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [] });

s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -327,9 +340,15 @@ test("tryGet an existing object from the bucket", async () => {
s3Mock
.on(GetObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.resolves({ Body: createMockStream(VALUE) });
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [{ Key: KEY }] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({
AcceptRanges: "bytes",
ContentLength: 3191,
ContentType: "image/jpeg",
ETag: "6805f2cfc46c0f04559748bb039d69ae",
LastModified: new Date("Thu, 15 Dec 2016 01:19:41 GMT"),
Metadata: {},
VersionId: "null",
});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -347,9 +366,7 @@ test("tryGet a non-existent object from the bucket", async () => {
s3Mock
.on(GetObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.rejects(new Error("fake error"));
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -367,9 +384,15 @@ test("tryGetJson an existing object from the bucket", async () => {
s3Mock
.on(GetObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.resolves({ Body: createMockStream(JSON.stringify(VALUE)) });
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [{ Key: KEY }] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({
AcceptRanges: "bytes",
ContentLength: 3191,
ContentType: "image/jpeg",
ETag: "6805f2cfc46c0f04559748bb039d69ae",
LastModified: new Date("Thu, 15 Dec 2016 01:19:41 GMT"),
Metadata: {},
VersionId: "null",
});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -387,9 +410,7 @@ test("tryGetJson a non-existent object from the bucket", async () => {
s3Mock
.on(GetObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.rejects(new Error("fake error"));
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -407,9 +428,15 @@ test("tryGetJson an existing non-Json object from the bucket", async () => {
s3Mock
.on(GetObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.resolves({ Body: createMockStream(VALUE) });
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [{ Key: KEY }] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({
AcceptRanges: "bytes",
ContentLength: 3191,
ContentType: "image/jpeg",
ETag: "6805f2cfc46c0f04559748bb039d69ae",
LastModified: new Date("Thu, 15 Dec 2016 01:19:41 GMT"),
Metadata: {},
VersionId: "null",
});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -426,9 +453,15 @@ test("tryDelete an existing object from the bucket", async () => {
s3Mock
.on(DeleteObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.resolves({});
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [{ Key: KEY }] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({
AcceptRanges: "bytes",
ContentLength: 3191,
ContentType: "image/jpeg",
ETag: "6805f2cfc46c0f04559748bb039d69ae",
LastModified: new Date("Thu, 15 Dec 2016 01:19:41 GMT"),
Metadata: {},
VersionId: "null",
});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand All @@ -445,9 +478,7 @@ test("tryDelete a non-existent object from the bucket", async () => {
s3Mock
.on(DeleteObjectCommand, { Bucket: BUCKET_NAME, Key: KEY })
.resolves({});
s3Mock
.on(ListObjectsV2Command, { Bucket: BUCKET_NAME, Prefix: KEY, MaxKeys: 1 })
.resolves({ Contents: [] });
s3Mock.on(HeadObjectCommand, { Bucket: BUCKET_NAME, Key: KEY }).resolves({});

// WHEN
const client = new BucketClient(BUCKET_NAME);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ module.exports = function({ $b }) {
"uniqueId": "testdelete_Handler_IamRolePolicy_D6FF0B67"
}
},
"policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":[\"s3:List*\",\"s3:PutObject*\",\"s3:Abort*\",\"s3:DeleteObject*\",\"s3:DeleteObjectVersion*\",\"s3:PutLifecycleConfiguration*\"],\"Resource\":[\"${aws_s3_bucket.cloudBucket.arn}\",\"${aws_s3_bucket.cloudBucket.arn}/*\"],\"Effect\":\"Allow\"}]}",
"policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":[\"s3:List*\",\"s3:PutObject*\",\"s3:Abort*\",\"s3:GetObject*\",\"s3:GetBucket*\",\"s3:DeleteObject*\",\"s3:DeleteObjectVersion*\",\"s3:PutLifecycleConfiguration*\"],\"Resource\":[\"${aws_s3_bucket.cloudBucket.arn}\",\"${aws_s3_bucket.cloudBucket.arn}/*\"],\"Effect\":\"Allow\"}]}",
"role": "${aws_iam_role.testdelete_Handler_IamRole_0B29F48A.name}"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ module.exports = function({ $b }) {
"uniqueId": "testexists_Handler_IamRolePolicy_46744240"
}
},
"policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":[\"s3:List*\",\"s3:PutObject*\",\"s3:Abort*\",\"s3:DeleteObject*\",\"s3:DeleteObjectVersion*\",\"s3:PutLifecycleConfiguration*\"],\"Resource\":[\"${aws_s3_bucket.cloudBucket.arn}\",\"${aws_s3_bucket.cloudBucket.arn}/*\"],\"Effect\":\"Allow\"}]}",
"policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":[\"s3:List*\",\"s3:PutObject*\",\"s3:Abort*\",\"s3:GetObject*\",\"s3:GetBucket*\",\"s3:DeleteObject*\",\"s3:DeleteObjectVersion*\",\"s3:PutLifecycleConfiguration*\"],\"Resource\":[\"${aws_s3_bucket.cloudBucket.arn}\",\"${aws_s3_bucket.cloudBucket.arn}/*\"],\"Effect\":\"Allow\"}]}",
"role": "${aws_iam_role.testexists_Handler_IamRole_4F58045B.name}"
}
},
Expand Down

0 comments on commit a561686

Please sign in to comment.