Skip to content

Commit 21c0092

Browse files
committed
fix(lib-dynamodb): support command reuse
1 parent c63c1e3 commit 21c0092

File tree

5 files changed

+336
-2
lines changed

5 files changed

+336
-2
lines changed

lib/lib-dynamodb/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo",
1616
"extract:docs": "api-extractor run --local",
1717
"test": "yarn g:vitest run",
18-
"test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development",
1918
"test:watch": "yarn g:vitest watch",
20-
"test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts"
19+
"test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development",
20+
"test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts",
21+
"test:integration": "yarn g:vitest run -c vitest.config.integ.ts --mode development",
22+
"test:integration:watch": "yarn g:vitest watch -c vitest.config.integ.ts"
2123
},
2224
"engines": {
2325
"node": ">=18.0.0"

lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class AnyCommand extends DynamoDBDocumentClientCommand<{}, {}, {}, {}, {}> {
2020
addRelativeTo(fn: any, config: any) {
2121
this.argCaptor.push([fn, config]);
2222
},
23+
add(fn: any, config: any) {},
2324
},
2425
} as any;
2526
protected readonly clientCommandName = "AnyCommand";

lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { setFeature } from "@aws-sdk/core";
2+
import { NumberValueImpl as NumberValue } from "@aws-sdk/util-dynamodb";
23
import { Command as $Command } from "@smithy/smithy-client";
34
import {
45
DeserializeHandler,
@@ -42,6 +43,23 @@ export abstract class DynamoDBDocumentClientCommand<
4243
marshallOptions.convertTopLevelContainer = marshallOptions.convertTopLevelContainer ?? true;
4344
unmarshallOptions.convertWithoutMapWrapper = unmarshallOptions.convertWithoutMapWrapper ?? true;
4445

46+
this.clientCommand.middlewareStack.add(
47+
(next: InitializeHandler<Input | BaseInput, Output | BaseOutput>, context: HandlerExecutionContext) =>
48+
async (
49+
args: InitializeHandlerArguments<Input | BaseInput>
50+
): Promise<InitializeHandlerOutput<Output | BaseOutput>> => {
51+
return next({
52+
...args,
53+
input: this.getCommandInput(),
54+
});
55+
},
56+
{
57+
name: "DocumentInitCopyInput",
58+
step: "initialize",
59+
priority: "high",
60+
override: true,
61+
}
62+
);
4563
this.clientCommand.middlewareStack.addRelativeTo(
4664
(next: InitializeHandler<Input | BaseInput, Output | BaseOutput>, context: HandlerExecutionContext) =>
4765
async (
@@ -75,4 +93,46 @@ export abstract class DynamoDBDocumentClientCommand<
7593
}
7694
);
7795
}
96+
97+
/**
98+
* For snapshotting the user input as the request starts.
99+
* The reason for this is to prevent mutations to the Command instance's inputs
100+
* from being carried over if the Command instance is reused in a new
101+
* request.
102+
*/
103+
private getCommandInput(): Input | BaseInput {
104+
return this.documentClone(this.input);
105+
}
106+
107+
/**
108+
* Recursive clone of types applicable to DynamoDBDocument.
109+
*/
110+
private documentClone(it: any): any {
111+
if (it === null || it === undefined) {
112+
return it;
113+
}
114+
if (it instanceof Set) {
115+
return new Set(it.values());
116+
}
117+
if (it instanceof Map) {
118+
return new Map(it.entries());
119+
}
120+
if (typeof it === "object") {
121+
if (it instanceof NumberValue) {
122+
return new NumberValue(it.value);
123+
}
124+
if (it instanceof Uint8Array) {
125+
return new Uint8Array(it);
126+
}
127+
if (Array.isArray(it)) {
128+
return it.map((i) => this.documentClone(i));
129+
}
130+
const out = {} as any;
131+
for (const [key, value] of Object.entries(it)) {
132+
out[key] = this.documentClone(value);
133+
}
134+
return out;
135+
}
136+
return it;
137+
}
78138
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { DynamoDB, ScanCommand } from "@aws-sdk/client-dynamodb";
2+
import { HeadBucketCommand, HeadBucketCommandInput, S3Client } from "@aws-sdk/client-s3";
3+
import { DynamoDBDocument, ScanCommand as DocumentScanCommand, ScanCommandInput } from "@aws-sdk/lib-dynamodb";
4+
import { describe, expect, test as it } from "vitest";
5+
6+
import { requireRequestsFrom } from "../../../../private/aws-util-test/src";
7+
8+
describe("DynamoDBDocument command mutability", () => {
9+
it("should allow sending the same command more than once without mutating the Command instance", async () => {
10+
const ddb = new DynamoDB({
11+
region: "us-west-2",
12+
});
13+
14+
const doc = DynamoDBDocument.from(ddb);
15+
16+
doc.middlewareStack.add(
17+
(next) => async (args) => {
18+
(args.input as any).TableName = "modified-by-middleware";
19+
return next(args);
20+
},
21+
{
22+
name: "input-modifying-custom-middleware",
23+
}
24+
);
25+
26+
let requestCount = 0;
27+
28+
requireRequestsFrom(doc).toMatch({
29+
hostname: /dynamodb/,
30+
body(json: string) {
31+
const requestBody = JSON.parse(json);
32+
if (requestCount === 0) {
33+
expect(requestBody).toEqual({
34+
ExpressionAttributeValues: { ":id": { S: "1" } },
35+
FilterExpression: "id = :id",
36+
TableName: "modified-by-middleware",
37+
});
38+
} else if (requestCount === 1) {
39+
expect(requestBody).toEqual({
40+
ExpressionAttributeValues: { ":id": { S: "1" } },
41+
FilterExpression: "id = :id",
42+
TableName: "modified-by-middleware",
43+
ExclusiveStartKey: {
44+
id: {
45+
S: "abc",
46+
},
47+
},
48+
});
49+
} else if (requestCount === 2) {
50+
expect(requestBody).toEqual({
51+
ExpressionAttributeValues: { ":id": { S: "1" } },
52+
FilterExpression: "id = :id",
53+
TableName: "modified-by-middleware",
54+
ExclusiveStartKey: {
55+
id: { S: "def" },
56+
},
57+
});
58+
} else if (requestCount === 3) {
59+
expect(requestBody).toEqual({
60+
ExpressionAttributeValues: { ":id": { S: "1" } },
61+
FilterExpression: "id = :id",
62+
TableName: "modified-by-middleware",
63+
ExclusiveStartKey: {
64+
id: { S: "ghi" },
65+
},
66+
});
67+
}
68+
requestCount += 1;
69+
},
70+
});
71+
72+
const params: ScanCommandInput = {
73+
TableName: "test",
74+
FilterExpression: "id = :id",
75+
ExpressionAttributeValues: {
76+
":id": "1",
77+
},
78+
};
79+
80+
const command = new DocumentScanCommand(params);
81+
82+
await doc.send(command);
83+
params.ExclusiveStartKey = { id: "abc" };
84+
await doc.send(command);
85+
params.ExclusiveStartKey = { id: "def" };
86+
await doc.send(command);
87+
params.ExclusiveStartKey = { id: "ghi" };
88+
await doc.send(command);
89+
90+
// params should remain what it was set to by the caller,
91+
// disregarding middleware modifications and mutations
92+
// applied by the marshaller.
93+
expect(params).toEqual({
94+
TableName: "test",
95+
FilterExpression: "id = :id",
96+
ExpressionAttributeValues: {
97+
":id": "1",
98+
},
99+
ExclusiveStartKey: {
100+
id: "ghi",
101+
},
102+
});
103+
104+
expect.assertions(9);
105+
});
106+
107+
it("the base dynamodb client can also use Command instances repeatedly", async () => {
108+
const ddb = new DynamoDB({
109+
region: "us-west-2",
110+
});
111+
112+
ddb.middlewareStack.add(
113+
(next) => async (args) => {
114+
(args.input as any).TableName = "modified-by-middleware";
115+
return next(args);
116+
},
117+
{
118+
name: "input-modifying-custom-middleware",
119+
}
120+
);
121+
122+
let requestCount = 0;
123+
124+
requireRequestsFrom(ddb).toMatch({
125+
hostname: /dynamodb/,
126+
body(json: string) {
127+
const requestBody = JSON.parse(json);
128+
if (requestCount === 0) {
129+
expect(requestBody).toEqual({
130+
ExpressionAttributeValues: { ":id": { S: "1" } },
131+
FilterExpression: "id = :id",
132+
TableName: "modified-by-middleware",
133+
});
134+
} else if (requestCount === 1) {
135+
expect(requestBody).toEqual({
136+
ExpressionAttributeValues: { ":id": { S: "1" } },
137+
FilterExpression: "id = :id",
138+
TableName: "modified-by-middleware",
139+
ExclusiveStartKey: {
140+
id: {
141+
S: "abc",
142+
},
143+
},
144+
});
145+
} else if (requestCount === 2) {
146+
expect(requestBody).toEqual({
147+
ExpressionAttributeValues: { ":id": { S: "1" } },
148+
FilterExpression: "id = :id",
149+
TableName: "modified-by-middleware",
150+
ExclusiveStartKey: {
151+
id: { S: "def" },
152+
},
153+
});
154+
} else if (requestCount === 3) {
155+
expect(requestBody).toEqual({
156+
ExpressionAttributeValues: { ":id": { S: "1" } },
157+
FilterExpression: "id = :id",
158+
TableName: "modified-by-middleware",
159+
ExclusiveStartKey: {
160+
id: { S: "ghi" },
161+
},
162+
});
163+
}
164+
requestCount += 1;
165+
},
166+
});
167+
168+
const params: ScanCommandInput = {
169+
TableName: "test",
170+
FilterExpression: "id = :id",
171+
ExpressionAttributeValues: {
172+
":id": { S: "1" },
173+
},
174+
};
175+
176+
const command = new ScanCommand(params);
177+
178+
await ddb.send(command);
179+
params.ExclusiveStartKey = { id: { S: "abc" } };
180+
await ddb.send(command);
181+
params.ExclusiveStartKey = { id: { S: "def" } };
182+
await ddb.send(command);
183+
params.ExclusiveStartKey = { id: { S: "ghi" } };
184+
await ddb.send(command);
185+
186+
// for regular clients, middleware modifications to the
187+
// args.input object persist beyond the request.
188+
expect(params).toEqual({
189+
TableName: "modified-by-middleware",
190+
FilterExpression: "id = :id",
191+
ExpressionAttributeValues: {
192+
":id": { S: "1" },
193+
},
194+
ExclusiveStartKey: {
195+
id: {
196+
S: "ghi",
197+
},
198+
},
199+
});
200+
201+
expect.assertions(9);
202+
});
203+
204+
it("other clients can also use Command instances repeatedly", async () => {
205+
const s3 = new S3Client({
206+
region: "us-west-2",
207+
});
208+
209+
s3.middlewareStack.add(
210+
(next) => async (args) => {
211+
(args.input as any).ExpectedBucketOwner = "me";
212+
return next(args);
213+
},
214+
{
215+
name: "input-modifying-custom-middleware",
216+
}
217+
);
218+
219+
let requestCount = 0;
220+
221+
requireRequestsFrom(s3).toMatch({
222+
headers: {
223+
"x-amz-expected-bucket-owner": /^me$/,
224+
},
225+
hostname: (h: string) => {
226+
if (requestCount === 0) {
227+
expect(h).toEqual(`bucket1.s3.us-west-2.amazonaws.com`);
228+
} else if (requestCount === 1) {
229+
expect(h).toEqual(`bucket2.s3.us-west-2.amazonaws.com`);
230+
} else if (requestCount === 2) {
231+
expect(h).toEqual(`bucket3.s3.us-west-2.amazonaws.com`);
232+
} else if (requestCount === 3) {
233+
expect(h).toEqual(`bucket4.s3.us-west-2.amazonaws.com`);
234+
}
235+
requestCount += 1;
236+
},
237+
});
238+
239+
const params: HeadBucketCommandInput = {
240+
Bucket: "bucket1",
241+
};
242+
243+
const command = new HeadBucketCommand(params);
244+
245+
await s3.send(command);
246+
params.Bucket = "bucket2";
247+
await s3.send(command);
248+
params.Bucket = `bucket3`;
249+
await s3.send(command);
250+
params.Bucket = `bucket4`;
251+
await s3.send(command);
252+
253+
// for regular clients, middleware modifications to the
254+
// args.input object persist beyond the request.
255+
expect(params).toEqual({
256+
Bucket: "bucket4",
257+
ExpectedBucketOwner: "me",
258+
});
259+
260+
expect.assertions(9);
261+
});
262+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
exclude: ["**/*.{e2e,browser}.spec.ts"],
6+
include: ["**/*.integ.spec.ts"],
7+
environment: "node",
8+
},
9+
});

0 commit comments

Comments
 (0)