Skip to content

Commit 997b3b7

Browse files
refactor(sdkv3): migrate S3 Client (aws#6721)
## Problem This client is tightly coupled to some implementations, making this migration more difficult than usual. Additionally, v3 handles streams differently than v2. Also, some v2 features in the SDK are completely redone. For example, pre-signing a url: https://aws.amazon.com/blogs/developer/generate-presigned-url-modular-aws-sdk-javascript/. ## Solution - refactor or delete most of the tests in https://github.com/aws/aws-toolkit-vscode/blob/1dbaab43d3533cc35b8bcd705860c7199bc98264/packages/core/src/test/shared/clients/defaultS3Client.test.ts - Most of them don't add real value. They simply assert the SDK was called, or that errors propagate. These unit tests should be reserved for testing the logic built on top of these requests, not the requests themselves. - For tests that do add value, refactor to avoid stubbing the SDK (Ex. `listFiles` refactor). - Throw an explicit error when we reach a code path that is not web-supported. - Migrate to new pre-signing logic, described here: https://aws.amazon.com/blogs/developer/generate-presigned-url-modular-aws-sdk-javascript/ - Simplify list buckets since region is now added in response by default. - Unify Bucket type definitions. There originally was the S3 `Bucket` type exported by the SDK, our own `Bucket`, and `DefaultBucket`. `Bucket` was derived from `DefaultBucket`, but this made reading type signatures ambiguous at first glance. These are all now under S3Bucket. ## Verification To verify this migration didn't break anything I manually tested the following through the toolkit: - Create buckets (in console and toolkit) in three different regions, ensure they display in proper regions. - Create folders (nested as well). - Upload a file, view and edit the file through explorer. - Download the file, and verify my changes were saved through S3. - Generate a presigned url for objects in my bucket. - Delete a file, bucket, folder. - Verify copied ARN, name, and Path are correct through selection. ## Future Work - Downloading and uploading a file now supports web streams, which means that `GetObject` response body is now a `StreamingBlobPayloadOutputTypes` from `@smithy/types`. This is a general type for web/node streams, therefore we must cast to Node's `Readable` type. Ideally we would support working with the abstract stream directly and avoiding the cast. The approach is outlined here: https://docs.aws.amazon.com/code-library/latest/ug/s3_example_s3_Scenario_UsingLargeFiles_section.html. - E2E tests for uploading and downloading a file. - Remove mocks from https://github.com/aws/aws-toolkit-vscode/blob/1a313e4029930e1da48558f6c7b84f48036672e9/packages/core/src/test/awsService/s3/commands/uploadFile.test.ts. Mocked tests are extremely expensive to maintain because they inherently rely on implementation details. - There are three listBuckets-related methods that all do slightly different things. These should live in a single method, `listBuckets` and it should return an `AsyncCollection` for proper pagination support. - Reduce stub usage in the tests to avoid coupling to implementation. The entire test suite for the wrapper stubbed S3 v2 directly, making it very difficult to refactor the tests. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: aws-toolkit-automation <[email protected]>
1 parent da0b811 commit 997b3b7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1664
-1135
lines changed

package-lock.json

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

packages/core/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,8 @@
503503
"@aws-sdk/client-docdb-elastic": "<3.696.0",
504504
"@aws-sdk/client-ec2": "<3.696.0",
505505
"@aws-sdk/client-iam": "<3.696.0",
506+
"@aws-sdk/client-s3": "<3.696.0",
507+
"@aws-sdk/lib-storage": "<3.696.0",
506508
"@aws-sdk/client-lambda": "<3.696.0",
507509
"@aws-sdk/client-ssm": "<3.696.0",
508510
"@aws-sdk/client-sso": "<3.696.0",
@@ -514,6 +516,7 @@
514516
"@aws-sdk/protocol-http": "<3.696.0",
515517
"@aws-sdk/smithy-client": "<3.696.0",
516518
"@aws-sdk/util-arn-parser": "<3.696.0",
519+
"@aws-sdk/s3-request-presigner": "<3.696.0",
517520
"@aws/mynah-ui": "^4.23.1",
518521
"@gerhobbelt/gitignore-parser": "^0.2.0-9",
519522
"@iarna/toml": "^2.2.5",

packages/core/src/awsService/accessanalyzer/vue/iamPolicyChecks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
PolicyChecksUiClick,
2626
ValidatePolicyFindingType,
2727
} from './constants'
28-
import { DefaultS3Client, parseS3Uri } from '../../../shared/clients/s3Client'
28+
import { S3Client, parseS3Uri } from '../../../shared/clients/s3'
2929
import { ExpiredTokenException } from '@aws-sdk/client-sso-oidc'
3030
import { ChildProcess } from '../../../shared/utilities/processUtils'
3131

@@ -842,7 +842,7 @@ export async function _readCustomChecksFile(input: string): Promise<string> {
842842
} else {
843843
try {
844844
const [region, bucket, key] = parseS3Uri(input)
845-
const s3Client = new DefaultS3Client(region)
845+
const s3Client = new S3Client(region)
846846
const resp = await s3Client.getObject({ bucketName: bucket, key })
847847
// Lint warning: this may evaluate to '[object Object]'. @typescript-eslint/no-base-to-string
848848
// eslint-disable-next-line @typescript-eslint/no-base-to-string

packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { defaultPartition } from '../../../../shared/regions/regionProvider'
1616
import { Lambda, APIGateway } from 'aws-sdk'
1717
import { LambdaNode } from '../../../../lambda/explorer/lambdaNodes'
1818
import { LambdaFunctionNode } from '../../../../lambda/explorer/lambdaFunctionNode'
19-
import { DefaultS3Client, DefaultBucket } from '../../../../shared/clients/s3Client'
19+
import { S3Client, toBucket } from '../../../../shared/clients/s3'
2020
import { S3Node } from '../../../../awsService/s3/explorer/s3Nodes'
2121
import { S3BucketNode } from '../../../../awsService/s3/explorer/s3BucketNode'
2222
import { ApiGatewayNode } from '../../../../awsService/apigateway/explorer/apiGatewayNodes'
@@ -100,13 +100,9 @@ export async function generateDeployedNode(
100100
break
101101
}
102102
case s3BucketType: {
103-
const s3Client = new DefaultS3Client(regionCode)
103+
const s3Client = new S3Client(regionCode)
104104
const s3Node = new S3Node(s3Client)
105-
const s3Bucket = new DefaultBucket({
106-
partitionId: partitionId,
107-
region: regionCode,
108-
name: deployedResource.PhysicalResourceId,
109-
})
105+
const s3Bucket = toBucket(deployedResource.PhysicalResourceId, regionCode, partitionId)
110106
newDeployedResource = new S3BucketNode(s3Bucket, s3Node, s3Client)
111107
break
112108
}

packages/core/src/awsService/s3/activation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { VirtualFileSystem } from '../../shared/virtualFilesystem'
2424
import { Commands } from '../../shared/vscode/commands2'
2525

2626
import * as nls from 'vscode-nls'
27-
import { DefaultS3Client } from '../../shared/clients/s3Client'
27+
import { S3Client } from '../../shared/clients/s3'
2828
import { TreeNode } from '../../shared/treeview/resourceTreeDataProvider'
2929
import { getSourceNode } from '../../shared/utilities/treeNodeUtils'
3030
const localize = nls.loadMessageBundle()
@@ -37,7 +37,7 @@ export async function activate(ctx: ExtContext): Promise<void> {
3737
const fs = new VirtualFileSystem(
3838
localize('AWS.s3.fileViewer.genericError', 'Unable to open S3 file, try reopening from the explorer')
3939
)
40-
const manager = new S3FileViewerManager((region) => new DefaultS3Client(region), fs)
40+
const manager = new S3FileViewerManager((region) => new S3Client(region), fs)
4141

4242
ctx.extensionContext.subscriptions.push(manager)
4343
ctx.extensionContext.subscriptions.push(
@@ -64,7 +64,7 @@ export async function activate(ctx: ExtContext): Promise<void> {
6464
if (!node) {
6565
const awsContext = ctx.awsContext
6666
const regionCode = awsContext.getCredentialDefaultRegion()
67-
const s3Client = new DefaultS3Client(regionCode)
67+
const s3Client = new S3Client(regionCode)
6868
const document = vscode.window.activeTextEditor?.document.uri
6969
await uploadFileCommand(s3Client, document)
7070
} else {

packages/core/src/awsService/s3/commands/createFolder.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import * as vscode from 'vscode'
7-
import { DEFAULT_DELIMITER } from '../../../shared/clients/s3Client'
7+
import { DEFAULT_DELIMITER } from '../../../shared/clients/s3'
88
import { getLogger } from '../../../shared/logger/logger'
99
import { S3BucketNode } from '../explorer/s3BucketNode'
1010
import { S3FolderNode } from '../explorer/s3FolderNode'
@@ -40,10 +40,10 @@ export async function createFolderCommand(node: S3BucketNode | S3FolderNode): Pr
4040
}
4141

4242
const path = node.path + folderName + DEFAULT_DELIMITER
43-
getLogger().info(`Creating folder "${path}" in bucket '${node.bucket.name}'`)
43+
getLogger().info(`Creating folder "${path}" in bucket '${node.bucket.Name}'`)
4444

4545
const { folder } = await node
46-
.createFolder({ path, bucketName: node.bucket.name })
46+
.createFolder({ path, bucketName: node.bucket.Name })
4747
.catch((e) => {
4848
const message = localize(
4949
'AWS.s3.createFolder.error.general',

packages/core/src/awsService/s3/commands/deleteBucket.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,23 @@ export async function deleteBucketCommand(node: S3BucketNode): Promise<void> {
3030
getLogger().debug('DeleteBucket called for %O', node)
3131

3232
await telemetry.s3_deleteBucket.run(async () => {
33-
const isConfirmed = await showConfirmationDialog(node.bucket.name)
33+
const isConfirmed = await showConfirmationDialog(node.bucket.Name)
3434
if (!isConfirmed) {
3535
throw new CancellationError('user')
3636
}
3737

38-
getLogger().info(`Deleting bucket: ${node.bucket.name}`)
38+
getLogger().info(`Deleting bucket: ${node.bucket.Name}`)
3939
await deleteWithProgress(node)
4040
.catch((e) => {
4141
const message = localize(
4242
'AWS.s3.deleteBucket.error.general',
4343
'Failed to delete bucket {0}',
44-
node.bucket.name
44+
node.bucket.Name
4545
)
4646
throw ToolkitError.chain(e, message)
4747
})
4848
.finally(() => refreshNode(node.parent))
49-
getLogger().info(`deleted bucket: ${node.bucket.name}`)
49+
getLogger().info(`deleted bucket: ${node.bucket.Name}`)
5050
})
5151
}
5252

@@ -65,7 +65,7 @@ async function deleteWithProgress(node: S3BucketNode): Promise<void> {
6565
return vscode.window.withProgress(
6666
{
6767
location: vscode.ProgressLocation.Notification,
68-
title: localize('AWS.s3.deleteBucket.progressTitle', 'Deleting {0}...', node.bucket.name),
68+
title: localize('AWS.s3.deleteBucket.progressTitle', 'Deleting {0}...', node.bucket.Name),
6969
},
7070
() => {
7171
return node.deleteBucket()

packages/core/src/awsService/s3/commands/downloadFileAs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { readablePath } from '../util'
1212
import { progressReporter } from '../progressReporter'
1313
import { localize } from '../../../shared/utilities/vsCodeUtils'
1414
import { showOutputMessage } from '../../../shared/utilities/messages'
15-
import { DefaultS3Client, S3Client } from '../../../shared/clients/s3Client'
15+
import { S3Client } from '../../../shared/clients/s3'
1616
import { Timeout, CancellationError } from '../../../shared/utilities/timeoutUtils'
1717
import { ToolkitError } from '../../../shared/errors'
1818
import { streamToBuffer, streamToFile } from '../../../shared/utilities/streamUtilities'
@@ -55,7 +55,7 @@ async function downloadS3File(
5555
file: S3File,
5656
options?: FileOptions | BufferOptions
5757
): Promise<Buffer | void> {
58-
const downloadStream = await client.downloadFileStream(file.bucket.name, file.key)
58+
const downloadStream = await client.downloadFileStream(file.bucket.Name, file.key)
5959
const result = options?.saveLocation
6060
? streamToFile(downloadStream, options.saveLocation)
6161
: streamToBuffer(downloadStream, file.sizeBytes)
@@ -86,7 +86,7 @@ async function downloadS3File(
8686
export async function downloadFile(file: S3File, options: FileOptions): Promise<void>
8787
export async function downloadFile(file: S3File, options?: BufferOptions): Promise<Buffer>
8888
export async function downloadFile(file: S3File, options?: FileOptions | BufferOptions): Promise<Buffer | void> {
89-
const client = options?.client ?? new DefaultS3Client(file.bucket.region)
89+
const client = options?.client ?? new S3Client(file.bucket.BucketRegion)
9090

9191
return downloadS3File(client, file, options).catch((err) => {
9292
const message = localize('AWS.s3.downloadFile.error.general', 'Failed to download file {0}', file.name)

packages/core/src/awsService/s3/commands/presignedURL.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { SignedUrlRequest } from '../../../shared/clients/s3Client'
6+
import { SignedUrlRequest } from '../../../shared/clients/s3'
77
import { copyToClipboard } from '../../../shared/utilities/messages'
88
import { S3FileNode } from '../explorer/s3FileNode'
99
import * as vscode from 'vscode'
@@ -19,13 +19,12 @@ export async function presignedURLCommand(node: S3FileNode): Promise<void> {
1919
const validTime = await promptTime(node.file.key)
2020
const s3Client = node.s3
2121
const request: SignedUrlRequest = {
22-
bucketName: node.bucket.name,
22+
bucketName: node.bucket.Name,
2323
key: node.file.key,
2424
time: validTime * 60,
25-
operation: 'getObject',
2625
}
2726

28-
const url = await s3Client.getSignedUrl(request).catch((e) => {
27+
const url = await s3Client.getSignedUrlForObject(request).catch((e) => {
2928
throw ToolkitError.chain(
3029
e,
3130
'Error creating the presigned URL. Make sure you have access to the requested file.'

packages/core/src/awsService/s3/commands/uploadFile.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@ import * as path from 'path'
77
import * as mime from 'mime-types'
88
import * as vscode from 'vscode'
99
import { statSync } from 'fs' // eslint-disable-line no-restricted-imports
10-
import { S3 } from 'aws-sdk'
1110
import { getLogger } from '../../../shared/logger/logger'
1211
import { S3Node } from '../explorer/s3Nodes'
1312
import { readablePath } from '../util'
1413
import { localize } from '../../../shared/utilities/vsCodeUtils'
1514
import { showOutputMessage } from '../../../shared/utilities/messages'
1615
import { createQuickPick, promptUser, verifySinglePickerOutput } from '../../../shared/ui/picker'
1716
import { addCodiconToString } from '../../../shared/utilities/textUtilities'
18-
import { Bucket, Folder, S3Client } from '../../../shared/clients/s3Client'
17+
import { S3Bucket, Folder, S3Client } from '../../../shared/clients/s3'
1918
import { createBucketCommand } from './createBucket'
2019
import { S3BucketNode } from '../explorer/s3BucketNode'
2120
import { S3FolderNode } from '../explorer/s3FolderNode'
@@ -24,6 +23,7 @@ import { CancellationError } from '../../../shared/utilities/timeoutUtils'
2423
import { progressReporter } from '../progressReporter'
2524
import globals from '../../../shared/extensionGlobals'
2625
import { telemetry } from '../../../shared/telemetry/telemetry'
26+
import { Upload } from '@aws-sdk/lib-storage'
2727

2828
export interface FileSizeBytes {
2929
/**
@@ -38,7 +38,7 @@ interface UploadRequest {
3838
fileLocation: vscode.Uri
3939
fileSizeBytes: number
4040
s3Client: S3Client
41-
ongoingUpload?: S3.ManagedUpload
41+
ongoingUpload?: Upload
4242
}
4343

4444
/**
@@ -97,7 +97,7 @@ export async function uploadFileCommand(
9797
uploadRequests.push(
9898
...filesToUpload.map((file) => {
9999
const key = node!.path + path.basename(file.fsPath)
100-
return fileToUploadRequest(node!.bucket.name, key, file)
100+
return fileToUploadRequest(node!.bucket.Name, key, file)
101101
})
102102
)
103103
if (node instanceof S3FolderNode) {
@@ -281,15 +281,15 @@ async function uploadBatchOfFiles(
281281

282282
token.onCancellationRequested((e) => {
283283
if (uploadRequests[requestIdx].ongoingUpload) {
284-
uploadRequests[requestIdx].ongoingUpload?.abort()
284+
void uploadRequests[requestIdx].ongoingUpload?.abort()
285285
}
286286
return failedRequests
287287
})
288288

289289
while (!token.isCancellationRequested && requestIdx < uploadRequests.length) {
290290
const request = uploadRequests[requestIdx]
291291
const fileName = path.basename(request.key)
292-
const destinationPath = readablePath({ bucket: { name: request.bucketName }, path: request.key })
292+
const destinationPath = readablePath({ bucket: { Name: request.bucketName }, path: request.key })
293293
showOutputMessage(
294294
localize('AWS.s3.uploadFile.startUpload', 'Uploading file {0} to {1}', fileName, destinationPath),
295295
outputChannel
@@ -377,23 +377,23 @@ async function uploadWithProgress(
377377

378378
const cancelled = new Promise<void>((_, reject) => {
379379
token.onCancellationRequested((e) => {
380-
currentStream.abort()
380+
void currentStream.abort()
381381
reject(new CancellationError('user'))
382382
})
383383
})
384384

385-
await Promise.race([currentStream.promise(), cancelled])
385+
await Promise.race([currentStream.done(), cancelled])
386386

387387
return (request.ongoingUpload = undefined)
388388
}
389389

390390
export interface BucketQuickPickItem extends vscode.QuickPickItem {
391-
bucket: S3.Bucket | undefined
391+
bucket: (Partial<S3Bucket> & { Name: string }) | undefined
392392
folder?: Folder | undefined
393393
}
394394

395395
interface SavedFolder {
396-
bucket: Bucket
396+
bucket: S3Bucket
397397
folder: Folder
398398
}
399399

@@ -411,9 +411,9 @@ export async function promptUserForBucket(
411411
promptUserFunction = promptUser,
412412
createBucket = createBucketCommand
413413
): Promise<BucketQuickPickItem | 'cancel' | 'back'> {
414-
let allBuckets: S3.Bucket[]
414+
let allBuckets: S3Bucket[]
415415
try {
416-
allBuckets = await s3client.listAllBuckets()
416+
allBuckets = (await s3client.listBuckets()).buckets
417417
} catch (e) {
418418
getLogger().error('Failed to list buckets from client %O', e)
419419
void vscode.window.showErrorMessage(
@@ -424,7 +424,7 @@ export async function promptUserForBucket(
424424

425425
const s3Buckets = allBuckets.filter((bucket) => {
426426
return bucket && bucket.Name
427-
}) as S3.Bucket[]
427+
})
428428

429429
const createNewBucket: BucketQuickPickItem = {
430430
label: localize('AWS.command.s3.createBucket', 'Create new bucket'),
@@ -443,7 +443,7 @@ export async function promptUserForBucket(
443443
lastFolderItem = {
444444
label: lastTouchedFolder.folder.name,
445445
description: '(last opened S3 folder)',
446-
bucket: { Name: lastTouchedFolder.bucket.name },
446+
bucket: { Name: lastTouchedFolder.bucket.Name },
447447
folder: lastTouchedFolder.folder,
448448
}
449449
}
@@ -454,7 +454,7 @@ export async function promptUserForBucket(
454454
lastUploadedFolderItem = {
455455
label: lastUploadedToFolder.folder.name,
456456
description: '(last uploaded-to S3 folder)',
457-
bucket: { Name: lastUploadedToFolder.bucket.name },
457+
bucket: { Name: lastUploadedToFolder.bucket.Name },
458458
folder: lastUploadedToFolder.folder,
459459
}
460460
}

0 commit comments

Comments
 (0)