diff --git a/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts b/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts index fd971288901eb..0945768cb7685 100644 --- a/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts +++ b/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts @@ -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, @@ -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', @@ -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[]), diff --git a/packages/nodes-base/nodes/Aws/S3/V2/BucketDescription.ts b/packages/nodes-base/nodes/Aws/S3/V2/BucketDescription.ts index 1fba6ba5074a8..5ffebe67a9cca 100644 --- a/packages/nodes-base/nodes/Aws/S3/V2/BucketDescription.ts +++ b/packages/nodes-base/nodes/Aws/S3/V2/BucketDescription.ts @@ -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', diff --git a/packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.operation.test.ts b/packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.operation.test.ts index 76ffb58c6e723..cd947bd79fbb2 100644 --- a/packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.operation.test.ts +++ b/packages/nodes-base/nodes/Aws/S3/test/V2/AwsS3.node.operation.test.ts @@ -352,3 +352,171 @@ describe('AWS S3 V2 Node - File Download', () => { }); }); }); + +describe('AWS S3 V2 Node - Bucket Search', () => { + const executeFunctionsMock = mockDeep(); + 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); + }); +});