Skip to content

Commit 8bebe2e

Browse files
committed
feat: enhance R2 object Get operation to download content with text/binary output options
1 parent a616111 commit 8bebe2e

5 files changed

Lines changed: 275 additions & 3 deletions

File tree

nodes/CloudflareR2/R2ObjectDescription.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const r2ObjectOperations: INodeProperties[] = [
2222
{
2323
name: 'Get',
2424
value: 'get',
25-
description: 'Get object metadata',
25+
description: "Download an object's content from a bucket",
2626
action: 'Get an object',
2727
},
2828
{
@@ -95,6 +95,51 @@ export const r2ObjectFields: INodeProperties[] = [
9595
},
9696
},
9797

98+
// ===========================================
99+
// Get (download) fields
100+
// ===========================================
101+
{
102+
displayName: 'Output Format',
103+
name: 'responseFormat',
104+
type: 'options',
105+
displayOptions: {
106+
show: {
107+
resource: ['object'],
108+
operation: ['get'],
109+
},
110+
},
111+
options: [
112+
{
113+
name: 'Text',
114+
value: 'text',
115+
description: 'Return the object content as text inside the JSON output',
116+
},
117+
{
118+
name: 'Binary File',
119+
value: 'binary',
120+
description: 'Return the object content as a downloadable binary property',
121+
},
122+
],
123+
default: 'text',
124+
description: 'How to return the downloaded object content',
125+
},
126+
{
127+
displayName: 'Put Output In Field',
128+
name: 'binaryPropertyName',
129+
type: 'string',
130+
required: true,
131+
default: 'data',
132+
placeholder: 'data',
133+
description: 'Name of the binary property to write the downloaded file to',
134+
displayOptions: {
135+
show: {
136+
resource: ['object'],
137+
operation: ['get'],
138+
responseFormat: ['binary'],
139+
},
140+
},
141+
},
142+
98143
// ===========================================
99144
// Upload fields
100145
// ===========================================

nodes/CloudflareR2/R2ObjectExecute.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,24 @@ import { r2ObjectExecute } from './R2ObjectExecute';
33
import {
44
cloudflareApiRequest,
55
cloudflareApiRequestAllItems,
6+
cloudflareApiRequestDownload,
67
cloudflareApiRequestRaw,
78
} from '../shared/GenericFunctions';
89

910
jest.mock('../shared/GenericFunctions', () => ({
1011
cloudflareApiRequest: jest.fn(),
1112
cloudflareApiRequestAllItems: jest.fn(),
13+
cloudflareApiRequestDownload: jest.fn(),
1214
cloudflareApiRequestRaw: jest.fn(),
1315
}));
1416

1517
const mockCloudflareApiRequest = cloudflareApiRequest as jest.MockedFunction<typeof cloudflareApiRequest>;
1618
const mockCloudflareApiRequestAllItems = cloudflareApiRequestAllItems as jest.MockedFunction<
1719
typeof cloudflareApiRequestAllItems
1820
>;
21+
const mockCloudflareApiRequestDownload = cloudflareApiRequestDownload as jest.MockedFunction<
22+
typeof cloudflareApiRequestDownload
23+
>;
1924
const mockCloudflareApiRequestRaw = cloudflareApiRequestRaw as jest.MockedFunction<
2025
typeof cloudflareApiRequestRaw
2126
>;
@@ -30,6 +35,11 @@ function createExecuteContext(
3035
helpers: {
3136
getBinaryDataBuffer: jest.fn(async () => binaryBuffer),
3237
assertBinaryData: jest.fn(() => binaryData),
38+
prepareBinaryData: jest.fn(async (data: Buffer, fileName: string, mimeType?: string) => ({
39+
data: data.toString('base64'),
40+
fileName,
41+
mimeType: mimeType ?? 'application/octet-stream',
42+
})),
3343
},
3444
} as unknown as IExecuteFunctions;
3545
}
@@ -190,3 +200,89 @@ describe('r2ObjectExecute upload', () => {
190200
]);
191201
});
192202
});
203+
204+
describe('r2ObjectExecute get (download)', () => {
205+
beforeEach(() => {
206+
jest.clearAllMocks();
207+
});
208+
209+
it('downloads object content and returns it as text by default', async () => {
210+
mockCloudflareApiRequestDownload.mockResolvedValueOnce({
211+
body: Buffer.from('# Hello\nWorld', 'utf-8'),
212+
contentType: 'text/markdown',
213+
});
214+
215+
const context = createExecuteContext({
216+
operation: 'get',
217+
accountId: 'acc123',
218+
bucketName: 'my-bucket',
219+
objectKey: 'docs/readme.md',
220+
responseFormat: 'text',
221+
});
222+
223+
const result = await r2ObjectExecute.call(context, 0);
224+
225+
expect(mockCloudflareApiRequestDownload).toHaveBeenCalledWith(
226+
'GET',
227+
'/accounts/acc123/r2/buckets/my-bucket/objects/docs%2Freadme.md',
228+
{},
229+
0,
230+
);
231+
expect(mockCloudflareApiRequest).not.toHaveBeenCalled();
232+
expect(result).toEqual([
233+
{
234+
json: {
235+
success: true,
236+
key: 'docs/readme.md',
237+
content: '# Hello\nWorld',
238+
contentType: 'text/markdown',
239+
size: Buffer.from('# Hello\nWorld', 'utf-8').length,
240+
},
241+
pairedItem: { item: 0 },
242+
},
243+
]);
244+
});
245+
246+
it('returns object content as a binary property when requested', async () => {
247+
const fileBytes = Buffer.from([0, 1, 2, 3, 4]);
248+
mockCloudflareApiRequestDownload.mockResolvedValueOnce({
249+
body: fileBytes,
250+
contentType: 'application/octet-stream',
251+
});
252+
253+
const context = createExecuteContext({
254+
operation: 'get',
255+
accountId: 'acc123',
256+
bucketName: 'my-bucket',
257+
objectKey: 'data/blob.bin',
258+
responseFormat: 'binary',
259+
binaryPropertyName: 'data',
260+
});
261+
262+
const result = await r2ObjectExecute.call(context, 0);
263+
264+
expect(context.helpers.prepareBinaryData).toHaveBeenCalledWith(
265+
fileBytes,
266+
'data/blob.bin',
267+
'application/octet-stream',
268+
);
269+
expect(result).toEqual([
270+
{
271+
json: {
272+
success: true,
273+
key: 'data/blob.bin',
274+
contentType: 'application/octet-stream',
275+
size: fileBytes.length,
276+
},
277+
binary: {
278+
data: {
279+
data: fileBytes.toString('base64'),
280+
fileName: 'data/blob.bin',
281+
mimeType: 'application/octet-stream',
282+
},
283+
},
284+
pairedItem: { item: 0 },
285+
},
286+
]);
287+
});
288+
});

nodes/CloudflareR2/R2ObjectExecute.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import {
77
cloudflareApiRequest,
88
cloudflareApiRequestAllItems,
9+
cloudflareApiRequestDownload,
910
cloudflareApiRequestRaw,
1011
} from '../shared/GenericFunctions';
1112

@@ -58,12 +59,47 @@ export async function r2ObjectExecute(
5859

5960
if (operation === 'get') {
6061
const objectKey = this.getNodeParameter('objectKey', itemIndex) as string;
62+
const responseFormat = this.getNodeParameter('responseFormat', itemIndex, 'text') as string;
6163

62-
responseData = await cloudflareApiRequest.call(
64+
const { body, contentType } = await cloudflareApiRequestDownload.call(
6365
this,
6466
'GET',
6567
`/accounts/${accountId}/r2/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}`,
68+
{},
69+
itemIndex,
6670
);
71+
72+
if (responseFormat === 'binary') {
73+
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', itemIndex, 'data') as string;
74+
75+
const binaryData = await this.helpers.prepareBinaryData(body, objectKey, contentType);
76+
77+
return [
78+
{
79+
json: {
80+
success: true,
81+
key: objectKey,
82+
contentType: contentType ?? binaryData.mimeType,
83+
size: body.length,
84+
},
85+
binary: { [binaryPropertyName]: binaryData },
86+
pairedItem: { item: itemIndex },
87+
},
88+
];
89+
}
90+
91+
return [
92+
{
93+
json: {
94+
success: true,
95+
key: objectKey,
96+
content: body.toString('utf-8'),
97+
contentType: contentType ?? 'text/plain',
98+
size: body.length,
99+
},
100+
pairedItem: { item: itemIndex },
101+
},
102+
];
67103
}
68104

69105
if (operation === 'delete') {

nodes/CloudflareRulesets/RulesetDescription.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ export const rulesetFields: INodeProperties[] = [
156156
{ name: 'HTTP Request Firewall Managed', value: 'http_request_firewall_managed' },
157157
{ name: 'HTTP Request Late Transform', value: 'http_request_late_transform' },
158158
{ name: 'HTTP Request Origin', value: 'http_request_origin' },
159-
{ name: 'HTTP Request Redirect', value: 'http_request_redirect' },
159+
{ name: 'HTTP Request Dynamic Redirect (Single Redirects)', value: 'http_request_dynamic_redirect' },
160+
{ name: 'HTTP Request Redirect (Bulk Redirects)', value: 'http_request_redirect' },
160161
{ name: 'HTTP Request Sanitize', value: 'http_request_sanitize' },
161162
{ name: 'HTTP Request Transform', value: 'http_request_transform' },
162163
{ name: 'HTTP Response Firewall Managed', value: 'http_response_firewall_managed' },

nodes/shared/GenericFunctions.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,100 @@ export async function cloudflareApiRequestRaw(
390390
}
391391
}
392392

393+
/**
394+
* Download a raw/binary object from Cloudflare (e.g. R2 object content).
395+
*
396+
* Unlike `cloudflareApiRequest` (which expects a JSON `{ success, result }`
397+
* envelope) this returns the raw bytes together with the response
398+
* `Content-Type` so the caller can surface the data as text or as an n8n
399+
* binary property. Used by the R2 object "Get" (download) operation.
400+
*/
401+
export async function cloudflareApiRequestDownload(
402+
this: IExecuteFunctions,
403+
method: IHttpRequestMethods,
404+
endpoint: string,
405+
qs: IDataObject = {},
406+
itemIndex?: number,
407+
): Promise<{ body: Buffer; contentType?: string }> {
408+
const options: IHttpRequestOptions = {
409+
method,
410+
url: `https://api.cloudflare.com/client/v4${endpoint}`,
411+
encoding: 'arraybuffer',
412+
returnFullResponse: true,
413+
json: false,
414+
};
415+
416+
if (Object.keys(qs).length > 0) {
417+
options.qs = qs;
418+
}
419+
420+
try {
421+
const response = (await this.helpers.httpRequestWithAuthentication.call(
422+
this,
423+
'cloudflareApi',
424+
options,
425+
)) as { body?: unknown; headers?: IDataObject };
426+
427+
const rawBody = response.body;
428+
let body: Buffer;
429+
if (Buffer.isBuffer(rawBody)) {
430+
body = rawBody;
431+
} else if (rawBody instanceof ArrayBuffer) {
432+
body = Buffer.from(new Uint8Array(rawBody));
433+
} else if (rawBody instanceof Uint8Array) {
434+
body = Buffer.from(rawBody);
435+
} else if (typeof rawBody === 'string') {
436+
body = Buffer.from(rawBody, 'utf-8');
437+
} else {
438+
body = Buffer.alloc(0);
439+
}
440+
441+
const headers = (response.headers ?? {}) as IDataObject;
442+
const contentType = headers['content-type'] as string | undefined;
443+
444+
return { body, contentType };
445+
} catch (error) {
446+
const err = error as {
447+
statusCode?: number;
448+
message?: string;
449+
httpCode?: string;
450+
response?: { body?: unknown };
451+
};
452+
453+
// Cloudflare returns a JSON error envelope even for download endpoints.
454+
let cfErrors: Array<{ message?: string; code?: number }> | undefined;
455+
const errorBody = err.response?.body;
456+
if (errorBody) {
457+
let parsed: { errors?: Array<{ message?: string; code?: number }> } | undefined;
458+
if (Buffer.isBuffer(errorBody)) {
459+
try {
460+
parsed = JSON.parse(errorBody.toString('utf-8'));
461+
} catch {
462+
// Ignore non-JSON error bodies
463+
}
464+
} else if (typeof errorBody === 'string') {
465+
try {
466+
parsed = JSON.parse(errorBody);
467+
} catch {
468+
// Ignore non-JSON error bodies
469+
}
470+
} else if (typeof errorBody === 'object') {
471+
parsed = errorBody as { errors?: Array<{ message?: string; code?: number }> };
472+
}
473+
cfErrors = parsed?.errors;
474+
}
475+
476+
const cfErrorMessage = cfErrors?.[0]?.message;
477+
const httpCode = err.httpCode || (err.statusCode ? String(err.statusCode) : undefined);
478+
479+
throw new NodeApiError(this.getNode(), error as JsonObject, {
480+
message: cfErrorMessage || err.message || 'Cloudflare object download failed',
481+
httpCode,
482+
itemIndex,
483+
});
484+
}
485+
}
486+
393487
/**
394488
* Make an API request to Cloudflare with NDJSON body format
395489
* Used for Vectorize insert/upsert operations that require application/x-ndjson

0 commit comments

Comments
 (0)