Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,18 @@ export class AwsS3V2 implements INodeType {

const region = responseData.LocationConstraint._ as string;

const includeCommonPrefixes = additionalFields.includeCommonPrefixes as
| boolean
| undefined;
const listPropertyName =
additionalFields.delimiter && includeCommonPrefixes
? 'ListBucketResult.CommonPrefixes'
: 'ListBucketResult.Contents';

if (returnAll) {
responseData = await awsApiRequestRESTAllItems.call(
this,
'ListBucketResult.Contents',
listPropertyName,
servicePath,
'GET',
basePath,
Expand All @@ -259,7 +267,7 @@ export class AwsS3V2 implements INodeType {
);
} else {
qs['max-keys'] = this.getNodeParameter('limit', 0);
responseData = await awsApiRequestREST.call(
const rawResponse = await awsApiRequestREST.call(
this,
servicePath,
'GET',
Expand All @@ -270,7 +278,11 @@ export class AwsS3V2 implements INodeType {
{},
region,
);
responseData = responseData.ListBucketResult.Contents;
const extracted =
additionalFields.delimiter && includeCommonPrefixes
? rawResponse.ListBucketResult?.CommonPrefixes
: rawResponse.ListBucketResult?.Contents;
responseData = Array.isArray(extracted) ? extracted : extracted ? [extracted] : [];
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject[]),
Expand Down
8 changes: 8 additions & 0 deletions packages/nodes-base/nodes/Aws/S3/V2/BucketDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,14 @@ export const bucketFields: INodeProperties[] = [
default: '',
description: 'A delimiter is a character you use to group keys',
},
{
displayName: 'Include Common Prefixes',
name: 'includeCommonPrefixes',
type: 'boolean',
default: false,
description:
'Whether to return common prefixes (grouped keys) instead of object contents. Only works when a Delimiter is set.',
},
{
displayName: 'Encoding Type',
name: 'encodingType',
Expand Down
168 changes: 168 additions & 0 deletions packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,171 @@ describe('AWS S3 V2 Node - File Download', () => {
});
});
});

describe('AWS S3 V2 Node - Bucket Search', () => {
const executeFunctionsMock = mockDeep<IExecuteFunctions>();
const awsApiRequestRESTSpy = jest.spyOn(GenericFunctions, 'awsApiRequestREST');
const awsApiRequestRESTAllItemsSpy = jest.spyOn(GenericFunctions, 'awsApiRequestRESTAllItems');
let node: AwsS3V2;

const mockContents = [
{ Key: 'file1.txt', Size: '100' },
{ Key: 'file2.txt', Size: '200' },
];
const mockCommonPrefixes = [{ Prefix: 'folder1/' }, { Prefix: 'folder2/' }];
const mockSearchResponse = {
ListBucketResult: {
Contents: mockContents,
CommonPrefixes: mockCommonPrefixes,
},
};

beforeEach(() => {
jest.resetAllMocks();
node = new AwsS3V2({
displayName: 'AWS S3',
name: 'awsS3',
icon: 'file:s3.svg',
group: ['output'],
description: 'Sends data to AWS S3',
});

executeFunctionsMock.getCredentials.mockResolvedValue({
accessKeyId: 'test-key',
secretAccessKey: 'test-secret',
region: 'eu-central-1',
});

executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 2 } as INode);
executeFunctionsMock.getInputData.mockReturnValue([{ json: {} }]);
executeFunctionsMock.continueOnFail.mockReturnValue(false);

executeFunctionsMock.helpers.returnJsonArray.mockImplementation((data) =>
Array.isArray(data) ? data.map((item) => ({ json: item })) : [{ json: data }],
);
executeFunctionsMock.helpers.constructExecutionMetaData.mockImplementation(
(data) => data as any,
);
});

function setupSearchParams(overrides: {
returnAll?: boolean;
delimiter?: string;
includeCommonPrefixes?: boolean;
limit?: number;
}) {
const {
returnAll = false,
delimiter = '',
includeCommonPrefixes = false,
limit = 100,
} = overrides;
executeFunctionsMock.getNodeParameter.mockImplementation((paramName, _i, defaultVal?) => {
switch (paramName) {
case 'resource':
return 'bucket';
case 'operation':
return 'search';
case 'bucketName':
return 'test-bucket';
case 'returnAll':
return returnAll;
case 'limit':
return limit;
case 'additionalFields':
return { delimiter, includeCommonPrefixes };
default:
return defaultVal ?? undefined;
}
});
}

it('should return Contents when no delimiter is set', async () => {
setupSearchParams({});
awsApiRequestRESTSpy
.mockResolvedValueOnce(mockLocationResponse)
.mockResolvedValueOnce(mockSearchResponse);

const result = await node.execute.call(executeFunctionsMock);

expect(result[0]).toHaveLength(mockContents.length);
expect(result[0][0].json).toEqual(mockContents[0]);
});

it('should return Contents when delimiter is set but includeCommonPrefixes is false', async () => {
setupSearchParams({ delimiter: '/', includeCommonPrefixes: false });
awsApiRequestRESTSpy
.mockResolvedValueOnce(mockLocationResponse)
.mockResolvedValueOnce(mockSearchResponse);

const result = await node.execute.call(executeFunctionsMock);

expect(result[0]).toHaveLength(mockContents.length);
expect(result[0][0].json).toEqual(mockContents[0]);
});

it('should return CommonPrefixes when delimiter is set and includeCommonPrefixes is true', async () => {
setupSearchParams({ delimiter: '/', includeCommonPrefixes: true });
awsApiRequestRESTSpy
.mockResolvedValueOnce(mockLocationResponse)
.mockResolvedValueOnce(mockSearchResponse);

const result = await node.execute.call(executeFunctionsMock);

expect(result[0]).toHaveLength(mockCommonPrefixes.length);
expect(result[0][0].json).toEqual(mockCommonPrefixes[0]);
});

it('should call awsApiRequestRESTAllItems with CommonPrefixes path when returnAll and includeCommonPrefixes are true', async () => {
setupSearchParams({ returnAll: true, delimiter: '/', includeCommonPrefixes: true });
awsApiRequestRESTSpy.mockResolvedValueOnce(mockLocationResponse);
awsApiRequestRESTAllItemsSpy.mockResolvedValueOnce(mockCommonPrefixes);

await node.execute.call(executeFunctionsMock);

expect(awsApiRequestRESTAllItemsSpy).toHaveBeenCalledWith(
'ListBucketResult.CommonPrefixes',
expect.any(String),
'GET',
expect.any(String),
'',
expect.any(Object),
{},
{},
expect.any(String),
);
});

it('should call awsApiRequestRESTAllItems with Contents path when returnAll is true and includeCommonPrefixes is false', async () => {
setupSearchParams({ returnAll: true, delimiter: '/', includeCommonPrefixes: false });
awsApiRequestRESTSpy.mockResolvedValueOnce(mockLocationResponse);
awsApiRequestRESTAllItemsSpy.mockResolvedValueOnce(mockContents);

await node.execute.call(executeFunctionsMock);

expect(awsApiRequestRESTAllItemsSpy).toHaveBeenCalledWith(
'ListBucketResult.Contents',
expect.any(String),
'GET',
expect.any(String),
'',
expect.any(Object),
{},
{},
expect.any(String),
);
});

it('should normalize a single-object Contents response to an array', async () => {
setupSearchParams({});
const singleItem = { Key: 'only-file.txt', Size: '50' };
awsApiRequestRESTSpy.mockResolvedValueOnce(mockLocationResponse).mockResolvedValueOnce({
ListBucketResult: { Contents: singleItem },
});

const result = await node.execute.call(executeFunctionsMock);

expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toEqual(singleItem);
});
});
Loading