Skip to content

Commit 95ba49c

Browse files
authored
feat(storage): add delimiter support to list API (aws-amplify#13517)
* feat: add types * feat: enable delimiter * chore: add unit tests * chore: bump bundle size * chore: add tsdocs * chore: address feedback * chore: address feedback * chore: address feedback
1 parent 0f5f4cb commit 95ba49c

File tree

7 files changed

+277
-15
lines changed

7 files changed

+277
-15
lines changed

packages/aws-amplify/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@
485485
"name": "[Storage] list (S3)",
486486
"path": "./dist/esm/storage/index.mjs",
487487
"import": "{ list }",
488-
"limit": "14.94 kB"
488+
"limit": "15.04 kB"
489489
},
490490
{
491491
"name": "[Storage] remove (S3)",

packages/storage/__tests__/providers/s3/apis/list.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,4 +512,167 @@ describe('list API', () => {
512512
}
513513
});
514514
});
515+
516+
describe('with delimiter', () => {
517+
const mockedContents = [
518+
{
519+
Key: 'photos/',
520+
...listObjectClientBaseResultItem,
521+
},
522+
{
523+
Key: 'photos/2023.png',
524+
...listObjectClientBaseResultItem,
525+
},
526+
{
527+
Key: 'photos/2024.png',
528+
...listObjectClientBaseResultItem,
529+
},
530+
];
531+
const mockedCommonPrefixes = [
532+
{ Prefix: 'photos/2023/' },
533+
{ Prefix: 'photos/2024/' },
534+
{ Prefix: 'photos/2025/' },
535+
];
536+
537+
const expectedExcludedSubpaths = mockedCommonPrefixes.map(
538+
({ Prefix }) => Prefix,
539+
);
540+
541+
const mockedPath = 'photos/';
542+
543+
beforeEach(() => {
544+
mockListObject.mockResolvedValueOnce({
545+
Contents: mockedContents,
546+
CommonPrefixes: mockedCommonPrefixes,
547+
});
548+
});
549+
afterEach(() => {
550+
jest.clearAllMocks();
551+
mockListObject.mockClear();
552+
});
553+
554+
it('should return excludedSubpaths when "exclude" strategy is passed in the request', async () => {
555+
const { items, excludedSubpaths } = await list({
556+
path: mockedPath,
557+
options: {
558+
subpathStrategy: { strategy: 'exclude' },
559+
},
560+
});
561+
expect(items).toHaveLength(3);
562+
expect(excludedSubpaths).toEqual(expectedExcludedSubpaths);
563+
expect(listObjectsV2).toHaveBeenCalledTimes(1);
564+
await expect(listObjectsV2).toBeLastCalledWithConfigAndInput(
565+
listObjectClientConfig,
566+
{
567+
Bucket: bucket,
568+
MaxKeys: 1000,
569+
Prefix: mockedPath,
570+
Delimiter: '/',
571+
},
572+
);
573+
});
574+
575+
it('should return excludedSubpaths when "exclude" strategy and listAll are passed in the request', async () => {
576+
const { items, excludedSubpaths } = await list({
577+
path: mockedPath,
578+
options: {
579+
subpathStrategy: { strategy: 'exclude' },
580+
listAll: true,
581+
},
582+
});
583+
expect(items).toHaveLength(3);
584+
expect(excludedSubpaths).toEqual(expectedExcludedSubpaths);
585+
expect(listObjectsV2).toHaveBeenCalledTimes(1);
586+
await expect(listObjectsV2).toBeLastCalledWithConfigAndInput(
587+
listObjectClientConfig,
588+
{
589+
Bucket: bucket,
590+
MaxKeys: 1000,
591+
Prefix: mockedPath,
592+
Delimiter: '/',
593+
},
594+
);
595+
});
596+
597+
it('should return excludedSubpaths when "exclude" strategy and pageSize are passed in the request', async () => {
598+
const { items, excludedSubpaths } = await list({
599+
path: mockedPath,
600+
options: {
601+
subpathStrategy: { strategy: 'exclude' },
602+
pageSize: 3,
603+
},
604+
});
605+
expect(items).toHaveLength(3);
606+
expect(excludedSubpaths).toEqual(expectedExcludedSubpaths);
607+
expect(listObjectsV2).toHaveBeenCalledTimes(1);
608+
await expect(listObjectsV2).toBeLastCalledWithConfigAndInput(
609+
listObjectClientConfig,
610+
{
611+
Bucket: bucket,
612+
MaxKeys: 3,
613+
Prefix: mockedPath,
614+
Delimiter: '/',
615+
},
616+
);
617+
});
618+
619+
it('should listObjectsV2 contain a custom Delimiter when "exclude" with delimiter is passed', async () => {
620+
await list({
621+
path: mockedPath,
622+
options: {
623+
subpathStrategy: {
624+
strategy: 'exclude',
625+
delimiter: '-',
626+
},
627+
},
628+
});
629+
expect(listObjectsV2).toHaveBeenCalledTimes(1);
630+
await expect(listObjectsV2).toBeLastCalledWithConfigAndInput(
631+
listObjectClientConfig,
632+
{
633+
Bucket: bucket,
634+
MaxKeys: 1000,
635+
Prefix: mockedPath,
636+
Delimiter: '-',
637+
},
638+
);
639+
});
640+
641+
it('should listObjectsV2 contain an undefined Delimiter when "include" strategy is passed', async () => {
642+
await list({
643+
path: mockedPath,
644+
options: {
645+
subpathStrategy: {
646+
strategy: 'include',
647+
},
648+
},
649+
});
650+
expect(listObjectsV2).toHaveBeenCalledTimes(1);
651+
await expect(listObjectsV2).toBeLastCalledWithConfigAndInput(
652+
listObjectClientConfig,
653+
{
654+
Bucket: bucket,
655+
MaxKeys: 1000,
656+
Prefix: mockedPath,
657+
Delimiter: undefined,
658+
},
659+
);
660+
});
661+
662+
it('should listObjectsV2 contain an undefined Delimiter when no options are passed', async () => {
663+
await list({
664+
path: mockedPath,
665+
});
666+
expect(listObjectsV2).toHaveBeenCalledTimes(1);
667+
await expect(listObjectsV2).toBeLastCalledWithConfigAndInput(
668+
listObjectClientConfig,
669+
{
670+
Bucket: bucket,
671+
MaxKeys: 1000,
672+
Prefix: mockedPath,
673+
Delimiter: undefined,
674+
},
675+
);
676+
});
677+
});
515678
});

packages/storage/src/providers/s3/apis/internal/list.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ import {
2828
} from '../../utils/client';
2929
import { getStorageUserAgentValue } from '../../utils/userAgent';
3030
import { logger } from '../../../../utils';
31-
import { STORAGE_INPUT_PREFIX } from '../../utils/constants';
31+
import { DEFAULT_DELIMITER, STORAGE_INPUT_PREFIX } from '../../utils/constants';
32+
import { CommonPrefix } from '../../utils/client/types';
33+
import { StorageSubpathStrategy } from '../../../../types';
3234

3335
const MAX_PAGE_SIZE = 1000;
3436

@@ -79,13 +81,15 @@ export const list = async (
7981
Prefix: isInputWithPrefix ? `${generatedPrefix}${objectKey}` : objectKey,
8082
MaxKeys: options?.listAll ? undefined : options?.pageSize,
8183
ContinuationToken: options?.listAll ? undefined : options?.nextToken,
84+
Delimiter: getDelimiter(options.subpathStrategy),
8285
};
8386
logger.debug(`listing items from "${listParams.Prefix}"`);
8487

8588
const listInputArgs: ListInputArgs = {
8689
s3Config,
8790
listParams,
8891
};
92+
8993
if (options.listAll) {
9094
if (isInputWithPrefix) {
9195
return _listAllWithPrefix({
@@ -176,23 +180,29 @@ const _listAllWithPath = async ({
176180
listParams,
177181
}: ListInputArgs): Promise<ListAllWithPathOutput> => {
178182
const listResult: ListOutputItemWithPath[] = [];
183+
const excludedSubpaths: string[] = [];
179184
let continuationToken = listParams.ContinuationToken;
180185
do {
181-
const { items: pageResults, nextToken: pageNextToken } =
182-
await _listWithPath({
183-
s3Config,
184-
listParams: {
185-
...listParams,
186-
ContinuationToken: continuationToken,
187-
MaxKeys: MAX_PAGE_SIZE,
188-
},
189-
});
186+
const {
187+
items: pageResults,
188+
excludedSubpaths: pageExcludedSubpaths,
189+
nextToken: pageNextToken,
190+
} = await _listWithPath({
191+
s3Config,
192+
listParams: {
193+
...listParams,
194+
ContinuationToken: continuationToken,
195+
MaxKeys: MAX_PAGE_SIZE,
196+
},
197+
});
190198
listResult.push(...pageResults);
199+
excludedSubpaths.push(...(pageExcludedSubpaths ?? []));
191200
continuationToken = pageNextToken;
192201
} while (continuationToken);
193202

194203
return {
195204
items: listResult,
205+
excludedSubpaths,
196206
};
197207
};
198208

@@ -206,27 +216,56 @@ const _listWithPath = async ({
206216
listParamsClone.MaxKeys = MAX_PAGE_SIZE;
207217
}
208218

209-
const response: ListObjectsV2Output = await listObjectsV2(
219+
const {
220+
Contents: contents,
221+
NextContinuationToken: nextContinuationToken,
222+
CommonPrefixes: commonPrefixes,
223+
}: ListObjectsV2Output = await listObjectsV2(
210224
{
211225
...s3Config,
212226
userAgentValue: getStorageUserAgentValue(StorageAction.List),
213227
},
214228
listParamsClone,
215229
);
216230

217-
if (!response?.Contents) {
231+
const excludedSubpaths =
232+
commonPrefixes && mapCommonPrefixesToExcludedSubpaths(commonPrefixes);
233+
234+
if (!contents) {
218235
return {
219236
items: [],
237+
excludedSubpaths,
220238
};
221239
}
222240

223241
return {
224-
items: response.Contents.map(item => ({
242+
items: contents.map(item => ({
225243
path: item.Key!,
226244
eTag: item.ETag,
227245
lastModified: item.LastModified,
228246
size: item.Size,
229247
})),
230-
nextToken: response.NextContinuationToken,
248+
nextToken: nextContinuationToken,
249+
excludedSubpaths,
231250
};
232251
};
252+
253+
const mapCommonPrefixesToExcludedSubpaths = (
254+
commonPrefixes: CommonPrefix[],
255+
): string[] => {
256+
return commonPrefixes.reduce((mappedSubpaths, { Prefix }) => {
257+
if (Prefix) {
258+
mappedSubpaths.push(Prefix);
259+
}
260+
261+
return mappedSubpaths;
262+
}, [] as string[]);
263+
};
264+
265+
const getDelimiter = (
266+
subpathStrategy?: StorageSubpathStrategy,
267+
): string | undefined => {
268+
if (subpathStrategy?.strategy === 'exclude') {
269+
return subpathStrategy?.delimiter ?? DEFAULT_DELIMITER;
270+
}
271+
};

packages/storage/src/providers/s3/utils/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ export const UPLOADS_STORAGE_KEY = '__uploadInProgress';
2323
export const STORAGE_INPUT_PREFIX = 'prefix';
2424
export const STORAGE_INPUT_KEY = 'key';
2525
export const STORAGE_INPUT_PATH = 'path';
26+
27+
export const DEFAULT_DELIMITER = '/';

packages/storage/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export {
2929
StorageRemoveOptions,
3030
StorageListAllOptions,
3131
StorageListPaginateOptions,
32+
StorageSubpathStrategy,
3233
} from './options';
3334
export {
3435
StorageItem,

packages/storage/src/types/options.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,65 @@ export interface StorageOptions {
1010

1111
export type StorageListAllOptions = StorageOptions & {
1212
listAll: true;
13+
subpathStrategy?: StorageSubpathStrategy;
1314
};
1415

1516
export type StorageListPaginateOptions = StorageOptions & {
1617
listAll?: false;
1718
pageSize?: number;
1819
nextToken?: string;
20+
subpathStrategy?: StorageSubpathStrategy;
1921
};
2022

2123
export type StorageRemoveOptions = StorageOptions;
24+
25+
export type StorageSubpathStrategy =
26+
| {
27+
/**
28+
* Default behavior. Includes all subpaths for a given page in the result.
29+
*/
30+
strategy: 'include';
31+
}
32+
| {
33+
/**
34+
* When passed, the output of the list API will provide a list of `excludedSubpaths`
35+
* that are delimited by the `/` (by default) character.
36+
*
37+
*
38+
* @example
39+
* ```ts
40+
* const { excludedSubpaths } = await list({
41+
* path: 'photos/',
42+
* options: {
43+
* subpathStrategy: {
44+
* strategy: 'exclude',
45+
* }
46+
* }
47+
* });
48+
*
49+
* console.log(excludedSubpaths);
50+
*
51+
* ```
52+
*/
53+
strategy: 'exclude';
54+
/**
55+
* Deliminate with with a custom delimiter character.
56+
*
57+
* @example
58+
* ```ts
59+
* const { excludedSubpaths } = await list({
60+
* path: 'photos/',
61+
* options: {
62+
* subpathStrategy: {
63+
* strategy: 'exclude',
64+
* delimiter: '-'
65+
* }
66+
* }
67+
* });
68+
*
69+
* console.log(excludedSubpaths);
70+
*
71+
* ```
72+
*/
73+
delimiter?: string;
74+
};

packages/storage/src/types/outputs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,8 @@ export interface StorageListOutput<Item extends StorageItem> {
7070
* List of items returned by the list API.
7171
*/
7272
items: Item[];
73+
/**
74+
* List of excluded subpaths when `exclude` is passed as part of the `subpathStrategy` of the input options.
75+
*/
76+
excludedSubpaths?: string[];
7377
}

0 commit comments

Comments
 (0)