Skip to content

Commit d46894c

Browse files
committed
Extend basic command for ListObjectV2
Issue: CLDSRVCLT-10
1 parent 1bba0e7 commit d46894c

File tree

2 files changed

+189
-15
lines changed

2 files changed

+189
-15
lines changed

src/clients/s3Extended.ts

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import {
77
ListObjectsCommandInput,
88
ListObjectsV2Command,
99
ListObjectsV2CommandInput,
10+
ListObjectsV2CommandOutput,
11+
_Object,
1012
ListObjectVersionsCommand,
1113
ListObjectVersionsCommandInput,
12-
ObjectAttributes
14+
ObjectAttributes,
15+
OptionalObjectAttributes
1316
} from '@aws-sdk/client-s3';
1417
import { streamCollector } from '@smithy/node-http-handler';
1518
import { XMLParser } from 'fast-xml-parser';
@@ -41,17 +44,47 @@ export class ListObjectsExtendedCommand extends ListObjectsCommand {
4144
}
4245

4346
export interface ListObjectsV2ExtendedInput extends ListObjectsV2CommandInput {
44-
Query: string;
47+
Query?: string;
48+
ObjectAttributes?: (OptionalObjectAttributes | `x-amz-meta-${string}`)[];
49+
}
50+
51+
export interface ListObjectsV2ExtendedContentEntry extends _Object {
52+
[key: `x-amz-meta-${string}`]: string;
53+
}
54+
55+
export interface ListObjectsV2ExtendedOutput extends ListObjectsV2CommandOutput {
56+
Contents?: ListObjectsV2ExtendedContentEntry[];
4557
}
4658

4759
export class ListObjectsV2ExtendedCommand extends ListObjectsV2Command {
4860
constructor(input: ListObjectsV2ExtendedInput) {
4961
super(input);
50-
62+
5163
this.middlewareStack.add(
5264
extendCommandWithExtraParametersMiddleware(input.Query),
5365
{ step: 'build', name: 'extendCommandWithExtraParameters' }
5466
);
67+
68+
if (input.ObjectAttributes?.length) {
69+
const captured = { xml: '' };
70+
71+
this.middlewareStack.add(overrideObjectAttributesHeaderMiddleware('x-amz-optional-object-attributes', input.ObjectAttributes), {
72+
step: 'build',
73+
name: 'overrideObjectAttributesHeader',
74+
});
75+
76+
this.middlewareStack.add(captureResponseBodyMiddleware(captured), {
77+
step: 'deserialize',
78+
name: 'captureResponseBody',
79+
priority: 'low',
80+
});
81+
82+
this.middlewareStack.add(parseListObjectsUserMetadataMiddleware(captured), {
83+
step: 'deserialize',
84+
name: 'parseUserMetadata',
85+
priority: 'high',
86+
});
87+
}
5588
}
5689
}
5790

@@ -71,29 +104,48 @@ export class ListObjectVersionsExtendedCommand extends ListObjectVersionsCommand
71104
}
72105

73106
// eslint-disable-next-line @typescript-eslint/no-explicit-any
74-
const overrideObjectAttributesHeaderMiddleware = (attributes: string[]) => (next: any) => async (args: any) => {
107+
const overrideObjectAttributesHeaderMiddleware = (headerName: string, attributes: string[]) => (next: any) => async (args: any) => {
75108
const request = args.request;
76-
request.headers['x-amz-object-attributes'] = attributes.join(',');
109+
request.headers[headerName] = attributes.join(',');
77110
return next(args);
78111
};
79112

80113
const USER_METADATA_PREFIX = 'x-amz-meta-';
81114

82-
function parseUserMetadataFromXml(xml: string): Record<string, string> {
115+
function extractUserMetadata(obj: Record<string, any>): Record<string, string> {
116+
const metadata: Record<string, string> = {};
117+
for (const [key, value] of Object.entries(obj)) {
118+
if (key.startsWith(USER_METADATA_PREFIX)) {
119+
metadata[key] = String(value);
120+
}
121+
}
122+
return metadata;
123+
}
124+
125+
function parseGetObjectAttributesUserMetadata(xml: string): Record<string, string> {
83126
const parsed = new XMLParser().parse(xml);
84127
const response = parsed?.GetObjectAttributesResponse;
85128
if (!response) {
86129
return {};
87130
}
131+
return extractUserMetadata(response);
132+
}
88133

89-
const metadata: Record<string, string> = {};
90-
for (const [key, value] of Object.entries(response)) {
91-
if (key.startsWith(USER_METADATA_PREFIX)) {
92-
metadata[key] = String(value);
134+
function parseListObjectsUserMetadata(xml: string): Map<string, Record<string, string>> {
135+
const parsed = new XMLParser().parse(xml);
136+
const result = parsed?.ListBucketResult;
137+
if (!result?.Contents) {
138+
return new Map();
139+
}
140+
const contents = Array.isArray(result.Contents) ? result.Contents : [result.Contents];
141+
const metadataByKey = new Map<string, Record<string, string>>();
142+
for (const entry of contents) {
143+
const metadata = extractUserMetadata(entry);
144+
if (Object.keys(metadata).length > 0 && entry.Key) {
145+
metadataByKey.set(String(entry.Key), metadata);
93146
}
94147
}
95-
96-
return metadata;
148+
return metadataByKey;
97149
}
98150

99151
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -114,7 +166,22 @@ const captureResponseBodyMiddleware = (captured: { xml: string }) => (next: any)
114166
// eslint-disable-next-line @typescript-eslint/no-explicit-any
115167
const parseUserMetadataMiddleware = (captured: { xml: string }) => (next: any) => async (args: any) => {
116168
const result = await next(args);
117-
Object.assign(result.output, parseUserMetadataFromXml(captured.xml));
169+
Object.assign(result.output, parseGetObjectAttributesUserMetadata(captured.xml));
170+
return result;
171+
};
172+
173+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
174+
const parseListObjectsUserMetadataMiddleware = (captured: { xml: string }) => (next: any) => async (args: any) => {
175+
const result = await next(args);
176+
const metadataByKey = parseListObjectsUserMetadata(captured.xml);
177+
if (result.output.Contents && metadataByKey.size > 0) {
178+
for (const content of result.output.Contents) {
179+
const metadata = metadataByKey.get(content.Key);
180+
if (metadata) {
181+
Object.assign(content, metadata);
182+
}
183+
}
184+
}
118185
return result;
119186
};
120187

@@ -132,7 +199,7 @@ export class GetObjectAttributesExtendedCommand extends GetObjectAttributesComma
132199

133200
const captured = { xml: '' };
134201

135-
this.middlewareStack.add(overrideObjectAttributesHeaderMiddleware(input.ObjectAttributes), {
202+
this.middlewareStack.add(overrideObjectAttributesHeaderMiddleware('x-amz-object-attributes', input.ObjectAttributes), {
136203
step: 'build',
137204
name: 'overrideObjectAttributesHeader',
138205
});

tests/testS3ExtendedApis.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectAttributesCommandOutput } from '@aws-sdk/client-s3';
1+
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
22
import { createTestClient, testConfig } from './testSetup';
33
import { describeForMongoBackend } from './testHelpers';
44
import assert from 'assert';
@@ -7,6 +7,7 @@ import {
77
GetObjectAttributesExtendedOutput,
88
ListObjectsExtendedCommand,
99
ListObjectsV2ExtendedCommand,
10+
ListObjectsV2ExtendedOutput,
1011
ListObjectVersionsExtendedCommand,
1112
} from '../src/clients/s3Extended';
1213

@@ -139,6 +140,112 @@ describeForMongoBackend('S3 Extended API Tests', () => {
139140
});
140141
});
141142

143+
describe.skip('ListObjectsV2 with ObjectAttributes', () => {
144+
const metaKey1 = `${testConfig.objectKey}-listv2-meta1`;
145+
const metaKey2 = `${testConfig.objectKey}-listv2-meta2`;
146+
147+
beforeAll(async () => {
148+
await s3client.send(new PutObjectCommand({
149+
Bucket: testConfig.bucketName,
150+
Key: metaKey1,
151+
Body: 'data1',
152+
Metadata: { foo: 'bar', baz: 'qux' },
153+
}));
154+
await s3client.send(new PutObjectCommand({
155+
Bucket: testConfig.bucketName,
156+
Key: metaKey2,
157+
Body: 'data2',
158+
Metadata: { foo: 'hello' },
159+
}));
160+
});
161+
162+
it('should list objects with a single user metadata key', async () => {
163+
const result = await s3client.send(new ListObjectsV2ExtendedCommand({
164+
Bucket: testConfig.bucketName,
165+
ObjectAttributes: ['x-amz-meta-foo'],
166+
})) as ListObjectsV2ExtendedOutput;
167+
168+
const obj1 = result.Contents!.find(content => content.Key === metaKey1)!;
169+
const obj2 = result.Contents!.find(ccontent => ccontent.Key === metaKey2)!;
170+
171+
assert.strictEqual(result.$metadata.httpStatusCode, 200);
172+
assert.strictEqual(result.Contents?.length, 2);
173+
assert.strictEqual(obj1['x-amz-meta-foo'], 'bar');
174+
assert.strictEqual(obj2['x-amz-meta-foo'], 'hello');
175+
});
176+
177+
it('should list objects with multiple user metadata keys', async () => {
178+
const result = await s3client.send(new ListObjectsV2ExtendedCommand({
179+
Bucket: testConfig.bucketName,
180+
ObjectAttributes: ['x-amz-meta-foo', 'x-amz-meta-baz'],
181+
})) as ListObjectsV2ExtendedOutput;
182+
183+
const obj1 = result.Contents!.find(content => content.Key === metaKey1)!;
184+
const obj2 = result.Contents!.find(ccontent => ccontent.Key === metaKey2)!;
185+
186+
assert.strictEqual(result.$metadata.httpStatusCode, 200);
187+
assert.strictEqual(obj1['x-amz-meta-foo'], 'bar');
188+
assert.strictEqual(obj1['x-amz-meta-baz'], 'qux');
189+
assert.strictEqual(obj2['x-amz-meta-foo'], 'hello');
190+
assert.strictEqual(obj2['x-amz-meta-baz'], undefined);
191+
});
192+
193+
it('should list objects with wildcard user metadata', async () => {
194+
const result = await s3client.send(new ListObjectsV2ExtendedCommand({
195+
Bucket: testConfig.bucketName,
196+
ObjectAttributes: ['x-amz-meta-*'],
197+
})) as ListObjectsV2ExtendedOutput;
198+
199+
const obj1 = result.Contents!.find(content => content.Key === metaKey1)!;
200+
const obj2 = result.Contents!.find(ccontent => ccontent.Key === metaKey2)!;
201+
202+
assert.strictEqual(result.$metadata.httpStatusCode, 200);
203+
assert.strictEqual(obj1['x-amz-meta-foo'], 'bar');
204+
assert.strictEqual(obj1['x-amz-meta-baz'], 'qux');
205+
assert.strictEqual(obj2['x-amz-meta-foo'], 'hello');
206+
});
207+
208+
it('should list objects with non-existing user metadata key', async () => {
209+
const result = await s3client.send(new ListObjectsV2ExtendedCommand({
210+
Bucket: testConfig.bucketName,
211+
ObjectAttributes: ['x-amz-meta-nonexistent'],
212+
})) as ListObjectsV2ExtendedOutput;
213+
214+
const obj1 = result.Contents!.find(content => content.Key === metaKey1)!;
215+
216+
assert.strictEqual(result.$metadata.httpStatusCode, 200);
217+
assert.strictEqual(result.Contents?.length, 2);
218+
assert.strictEqual(obj1['x-amz-meta-nonexistent'], undefined);
219+
});
220+
221+
it('should list objects with RestoreStatus combined with user metadata', async () => {
222+
const result = await s3client.send(new ListObjectsV2ExtendedCommand({
223+
Bucket: testConfig.bucketName,
224+
ObjectAttributes: ['RestoreStatus', 'x-amz-meta-foo'],
225+
})) as ListObjectsV2ExtendedOutput;
226+
227+
const obj1 = result.Contents!.find(content => content.Key === metaKey1)!;
228+
229+
assert.strictEqual(result.$metadata.httpStatusCode, 200);
230+
assert.strictEqual(obj1['x-amz-meta-foo'], 'bar');
231+
assert.deepStrictEqual(obj1.RestoreStatus, { IsRestoreInProgress: false });
232+
});
233+
234+
it('should list objects with RestoreStatus combined with non-existing user metadata', async () => {
235+
const result = await s3client.send(new ListObjectsV2ExtendedCommand({
236+
Bucket: testConfig.bucketName,
237+
ObjectAttributes: ['RestoreStatus', 'x-amz-meta-nonexistent'],
238+
})) as ListObjectsV2ExtendedOutput;
239+
240+
const obj1 = result.Contents!.find(content => content.Key === metaKey1)!;
241+
242+
assert.strictEqual(result.$metadata.httpStatusCode, 200);
243+
assert.strictEqual(result.Contents?.length, 2);
244+
assert.strictEqual(obj1['x-amz-meta-nonexistent'], undefined);
245+
assert.deepStrictEqual(obj1.RestoreStatus, { IsRestoreInProgress: false });
246+
});
247+
});
248+
142249
describe('GetObjectAttributes', () => {
143250
const metadataKey = `${testConfig.objectKey}-with-meta`;
144251
const metadataBody = 'data-with-metadata';

0 commit comments

Comments
 (0)