diff --git a/lib/lib-dynamodb/package.json b/lib/lib-dynamodb/package.json index d473f65f552aa..f93979d9633b0 100644 --- a/lib/lib-dynamodb/package.json +++ b/lib/lib-dynamodb/package.json @@ -15,9 +15,11 @@ "clean": "rimraf ./dist-* && rimraf *.tsbuildinfo", "extract:docs": "api-extractor run --local", "test": "yarn g:vitest run", - "test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development", "test:watch": "yarn g:vitest watch", - "test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts" + "test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development", + "test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts", + "test:integration": "yarn g:vitest run -c vitest.config.integ.ts --mode development", + "test:integration:watch": "yarn g:vitest watch -c vitest.config.integ.ts" }, "engines": { "node": ">=18.0.0" diff --git a/lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.spec.ts b/lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.spec.ts index d097b8eaafb81..4f985885c55b5 100644 --- a/lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.spec.ts +++ b/lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.spec.ts @@ -20,6 +20,7 @@ class AnyCommand extends DynamoDBDocumentClientCommand<{}, {}, {}, {}, {}> { addRelativeTo(fn: any, config: any) { this.argCaptor.push([fn, config]); }, + add(fn: any, config: any) {}, }, } as any; protected readonly clientCommandName = "AnyCommand"; diff --git a/lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.ts b/lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.ts index 8df7ab5cc777a..a5b1544c0e347 100644 --- a/lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.ts +++ b/lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.ts @@ -48,8 +48,18 @@ export abstract class DynamoDBDocumentClientCommand< args: InitializeHandlerArguments ): Promise> => { setFeature(context, "DDB_MAPPER", "d"); - args.input = marshallInput(args.input, this.inputKeyNodes, marshallOptions); - return next(args); + return next({ + ...args, + /** + * We overwrite `args.input` at this middleware, but do not + * mutate the args object itself, which is initially the Command instance. + * + * The reason for this is to prevent mutations to the Command instance's inputs + * from being carried over if the Command instance is reused in a new + * request. + */ + input: marshallInput(args.input, this.inputKeyNodes, marshallOptions), + }); }, { name: "DocumentMarshall", diff --git a/lib/lib-dynamodb/src/test/mutability.integ.spec.ts b/lib/lib-dynamodb/src/test/mutability.integ.spec.ts new file mode 100644 index 0000000000000..50d6978627d98 --- /dev/null +++ b/lib/lib-dynamodb/src/test/mutability.integ.spec.ts @@ -0,0 +1,259 @@ +import { DynamoDB, ScanCommand } from "@aws-sdk/client-dynamodb"; +import { HeadBucketCommand, HeadBucketCommandInput, S3Client } from "@aws-sdk/client-s3"; +import { DynamoDBDocument, ScanCommand as DocumentScanCommand, ScanCommandInput } from "@aws-sdk/lib-dynamodb"; +import { describe, expect, test as it } from "vitest"; + +import { requireRequestsFrom } from "../../../../private/aws-util-test/src"; + +describe("DynamoDBDocument command mutability", () => { + it("should allow sending the same command more than once without mutating the Command instance", async () => { + const ddb = new DynamoDB({ + region: "us-west-2", + }); + + const doc = DynamoDBDocument.from(ddb); + + doc.middlewareStack.add( + (next) => async (args) => { + (args.input as any).TableName = "modified-by-middleware"; + return next(args); + }, + { + name: "input-modifying-custom-middleware", + } + ); + + let requestCount = 0; + + requireRequestsFrom(doc).toMatch({ + hostname: /dynamodb/, + body(json: string) { + const requestBody = JSON.parse(json); + if (requestCount === 0) { + expect(requestBody).toEqual({ + ExpressionAttributeValues: { ":id": { S: "1" } }, + FilterExpression: "id = :id", + TableName: "modified-by-middleware", + }); + } else if (requestCount === 1) { + expect(requestBody).toEqual({ + ExpressionAttributeValues: { ":id": { S: "1" } }, + FilterExpression: "id = :id", + TableName: "modified-by-middleware", + ExclusiveStartKey: { + id: { + S: "abc", + }, + }, + }); + } else if (requestCount === 2) { + expect(requestBody).toEqual({ + ExpressionAttributeValues: { ":id": { S: "1" } }, + FilterExpression: "id = :id", + TableName: "modified-by-middleware", + ExclusiveStartKey: { + id: { S: "def" }, + }, + }); + } else if (requestCount === 3) { + expect(requestBody).toEqual({ + ExpressionAttributeValues: { ":id": { S: "1" } }, + FilterExpression: "id = :id", + TableName: "modified-by-middleware", + ExclusiveStartKey: { + id: { S: "ghi" }, + }, + }); + } + requestCount += 1; + }, + }); + + const params: ScanCommandInput = { + TableName: "test", + FilterExpression: "id = :id", + ExpressionAttributeValues: { + ":id": "1", + }, + }; + + const command = new DocumentScanCommand(params); + + await doc.send(command); + params.ExclusiveStartKey = { id: "abc" }; + await doc.send(command); + params.ExclusiveStartKey = { id: "def" }; + await doc.send(command); + params.ExclusiveStartKey = { id: "ghi" }; + await doc.send(command); + + // params should remain what it was set to by the caller, + // disregarding mutations applied by the AttributeValue marshaller. + expect(params).toEqual({ + TableName: "modified-by-middleware", + FilterExpression: "id = :id", + ExpressionAttributeValues: { + ":id": "1", + }, + ExclusiveStartKey: { + id: "ghi", + }, + }); + + expect.assertions(9); + }); + + it("the base dynamodb client can also use Command instances repeatedly", async () => { + const ddb = new DynamoDB({ + region: "us-west-2", + }); + + ddb.middlewareStack.add( + (next) => async (args) => { + (args.input as any).TableName = "modified-by-middleware"; + return next(args); + }, + { + name: "input-modifying-custom-middleware", + } + ); + + let requestCount = 0; + + requireRequestsFrom(ddb).toMatch({ + hostname: /dynamodb/, + body(json: string) { + const requestBody = JSON.parse(json); + if (requestCount === 0) { + expect(requestBody).toEqual({ + ExpressionAttributeValues: { ":id": { S: "1" } }, + FilterExpression: "id = :id", + TableName: "modified-by-middleware", + }); + } else if (requestCount === 1) { + expect(requestBody).toEqual({ + ExpressionAttributeValues: { ":id": { S: "1" } }, + FilterExpression: "id = :id", + TableName: "modified-by-middleware", + ExclusiveStartKey: { + id: { + S: "abc", + }, + }, + }); + } else if (requestCount === 2) { + expect(requestBody).toEqual({ + ExpressionAttributeValues: { ":id": { S: "1" } }, + FilterExpression: "id = :id", + TableName: "modified-by-middleware", + ExclusiveStartKey: { + id: { S: "def" }, + }, + }); + } else if (requestCount === 3) { + expect(requestBody).toEqual({ + ExpressionAttributeValues: { ":id": { S: "1" } }, + FilterExpression: "id = :id", + TableName: "modified-by-middleware", + ExclusiveStartKey: { + id: { S: "ghi" }, + }, + }); + } + requestCount += 1; + }, + }); + + const params: ScanCommandInput = { + TableName: "test", + FilterExpression: "id = :id", + ExpressionAttributeValues: { + ":id": { S: "1" }, + }, + }; + + const command = new ScanCommand(params); + + await ddb.send(command); + params.ExclusiveStartKey = { id: { S: "abc" } }; + await ddb.send(command); + params.ExclusiveStartKey = { id: { S: "def" } }; + await ddb.send(command); + params.ExclusiveStartKey = { id: { S: "ghi" } }; + await ddb.send(command); + + // for regular clients, middleware modifications to the + // args.input object also persist beyond the request. + expect(params).toEqual({ + TableName: "modified-by-middleware", + FilterExpression: "id = :id", + ExpressionAttributeValues: { + ":id": { S: "1" }, + }, + ExclusiveStartKey: { + id: { + S: "ghi", + }, + }, + }); + + expect.assertions(9); + }); + + it("other clients can also use Command instances repeatedly", async () => { + const s3 = new S3Client({ + region: "us-west-2", + }); + + s3.middlewareStack.add( + (next) => async (args) => { + (args.input as any).ExpectedBucketOwner = "me"; + return next(args); + }, + { + name: "input-modifying-custom-middleware", + } + ); + + let requestCount = 0; + + requireRequestsFrom(s3).toMatch({ + headers: { + "x-amz-expected-bucket-owner": /^me$/, + }, + hostname: (h: string) => { + if (requestCount === 0) { + expect(h).toEqual(`bucket1.s3.us-west-2.amazonaws.com`); + } else if (requestCount === 1) { + expect(h).toEqual(`bucket2.s3.us-west-2.amazonaws.com`); + } else if (requestCount === 2) { + expect(h).toEqual(`bucket3.s3.us-west-2.amazonaws.com`); + } else if (requestCount === 3) { + expect(h).toEqual(`bucket4.s3.us-west-2.amazonaws.com`); + } + requestCount += 1; + }, + }); + + const params: HeadBucketCommandInput = { + Bucket: "bucket1", + }; + + const command = new HeadBucketCommand(params); + + await s3.send(command); + params.Bucket = "bucket2"; + await s3.send(command); + params.Bucket = `bucket3`; + await s3.send(command); + params.Bucket = `bucket4`; + await s3.send(command); + + expect(params).toEqual({ + Bucket: "bucket4", + ExpectedBucketOwner: "me", + }); + + expect.assertions(9); + }); +}); diff --git a/lib/lib-dynamodb/vitest.config.integ.ts b/lib/lib-dynamodb/vitest.config.integ.ts new file mode 100644 index 0000000000000..57ed6111bdf06 --- /dev/null +++ b/lib/lib-dynamodb/vitest.config.integ.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/*.{e2e,browser}.spec.ts"], + include: ["**/*.integ.spec.ts"], + environment: "node", + }, +});