mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Confluence Node): Add attachments Get Many with file download (no-changelog) (#36713)
Co-authored-by: Ilfat Mindubaev <ilfat.mindubaev@n8n.io>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { returnAllOrLimit } from '@utils/descriptions';
|
||||
import { updateDisplayOptions } from '@utils/utilities';
|
||||
|
||||
import { confluenceApiRequest, confluenceApiRequestBinary } from '../../transport';
|
||||
import {
|
||||
PAGE_LIMIT,
|
||||
extractNextCursor,
|
||||
optionalSpaceRLC,
|
||||
pageRLC,
|
||||
parsePositiveInt,
|
||||
resolvePageId,
|
||||
} from '../common';
|
||||
import type { ConfluenceBinaryOperation } from '../router';
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
...optionalSpaceRLC,
|
||||
description:
|
||||
'Limits page selection and By Title lookups to one space. Leave empty or pick "All Spaces" to search across all spaces.',
|
||||
},
|
||||
{
|
||||
...pageRLC,
|
||||
description: 'The page whose attachments to fetch',
|
||||
},
|
||||
...returnAllOrLimit,
|
||||
{
|
||||
displayName: 'Download',
|
||||
name: 'download',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
"Whether to also download each attachment's file and attach it to the item's binary output",
|
||||
},
|
||||
{
|
||||
displayName: 'Put Output File in Field',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. data',
|
||||
default: 'data',
|
||||
required: true,
|
||||
description: 'Use this field name in the following nodes, to use the binary file data',
|
||||
hint: 'The name of the output binary field to put the file in',
|
||||
displayOptions: { show: { download: [true] } },
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
resource: ['attachment'],
|
||||
operation: ['getMany'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
export const execute: ConfluenceBinaryOperation = async function (
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
) {
|
||||
const pageId = await resolvePageId.call(this, itemIndex);
|
||||
const returnAll = this.getNodeParameter('returnAll', itemIndex, false);
|
||||
const limit = returnAll
|
||||
? Infinity
|
||||
: parsePositiveInt.call(
|
||||
this,
|
||||
this.getNodeParameter('limit', itemIndex, 100),
|
||||
'Limit',
|
||||
itemIndex,
|
||||
);
|
||||
const download = this.getNodeParameter('download', itemIndex, false);
|
||||
const endpoint = `/wiki/api/v2/pages/${encodeURIComponent(pageId)}/attachments`;
|
||||
|
||||
const attachments: IDataObject[] = [];
|
||||
let cursor: string | undefined;
|
||||
const seenCursors = new Set<string>();
|
||||
do {
|
||||
const qs: IDataObject = { limit: Math.min(limit - attachments.length, PAGE_LIMIT) };
|
||||
if (cursor !== undefined) qs.cursor = cursor;
|
||||
|
||||
const response = await confluenceApiRequest.call(this, 'GET', endpoint, {}, qs);
|
||||
const records = Array.isArray(response.results) ? (response.results as IDataObject[]) : [];
|
||||
attachments.push.apply(attachments, records);
|
||||
|
||||
const next = extractNextCursor(response);
|
||||
// A next link revisiting any earlier page would loop forever under Return All
|
||||
if (next === undefined || seenCursors.has(next)) break;
|
||||
seenCursors.add(next);
|
||||
cursor = next;
|
||||
} while (attachments.length < limit);
|
||||
|
||||
const items: INodeExecutionData[] = (returnAll ? attachments : attachments.slice(0, limit)).map(
|
||||
(attachment) => ({ json: attachment }),
|
||||
);
|
||||
|
||||
if (download) {
|
||||
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', itemIndex, 'data');
|
||||
for (const item of items) {
|
||||
const downloadLink = item.json.downloadLink;
|
||||
if (typeof downloadLink !== 'string' || !downloadLink.startsWith('/')) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Attachment "${asOptionalString(item.json.title) ?? asOptionalString(item.json.id) ?? 'unknown'}" has no usable download link`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
// downloadLink is server-relative to /wiki with a raw, unencoded filename in the
|
||||
// path; encode the segments and keep the query string (version/cache params) intact
|
||||
const querySplit = downloadLink.lastIndexOf('?');
|
||||
const path = (querySplit === -1 ? downloadLink : downloadLink.slice(0, querySplit))
|
||||
.split('/')
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
const query = querySplit === -1 ? '' : downloadLink.slice(querySplit);
|
||||
const buffer = await confluenceApiRequestBinary.call(this, `/wiki${path}${query}`);
|
||||
item.binary = {
|
||||
[binaryPropertyName]: await this.helpers.prepareBinaryData(
|
||||
buffer,
|
||||
asOptionalString(item.json.title),
|
||||
asOptionalString(item.json.mediaType),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as getMany from './getMany.operation';
|
||||
|
||||
export { getMany };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['attachment'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Get Many',
|
||||
value: 'getMany',
|
||||
description: 'List the attachments on a page, optionally downloading each file',
|
||||
action: 'Get many attachments',
|
||||
},
|
||||
],
|
||||
default: 'getMany',
|
||||
},
|
||||
...getMany.description,
|
||||
];
|
||||
@@ -196,14 +196,44 @@ export async function resolveSpaceKey(
|
||||
return space.key;
|
||||
}
|
||||
|
||||
export function extractNextCursor(response: IDataObject): string | undefined {
|
||||
export type NextPageParam = { key: 'cursor' | 'start'; value: string };
|
||||
|
||||
export function extractNextPageParam(response: IDataObject): NextPageParam | undefined {
|
||||
const next = (response._links as IDataObject | undefined)?.next;
|
||||
if (typeof next !== 'string' || next === '') return undefined;
|
||||
|
||||
let params: URLSearchParams;
|
||||
try {
|
||||
return new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined;
|
||||
params = new URL(next, 'https://api.atlassian.com').searchParams;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const cursor = params.get('cursor');
|
||||
if (cursor !== null && cursor !== '') return { key: 'cursor', value: cursor };
|
||||
// Older responses page by start offset instead of cursor
|
||||
const start = params.get('start');
|
||||
return start === null || start === '' ? undefined : { key: 'start', value: start };
|
||||
}
|
||||
|
||||
export function extractNextCursor(response: IDataObject): string | undefined {
|
||||
const next = extractNextPageParam(response);
|
||||
return next?.key === 'cursor' ? next.value : undefined;
|
||||
}
|
||||
|
||||
/** Validates a count parameter that an expression may hand back as a numeric string. */
|
||||
export function parsePositiveInt(
|
||||
this: IExecuteFunctions,
|
||||
raw: unknown,
|
||||
label: string,
|
||||
itemIndex: number,
|
||||
): number {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 1) {
|
||||
throw new NodeOperationError(this.getNode(), `${label} must be a number of at least 1`, {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import * as attachment from './attachment';
|
||||
import * as page from './page';
|
||||
import { CONFLUENCE_CREDENTIAL_NAME } from '../transport';
|
||||
|
||||
@@ -33,6 +34,10 @@ export const confluenceNodeDescription: INodeTypeDescription = {
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Attachment',
|
||||
value: 'attachment',
|
||||
},
|
||||
{
|
||||
name: 'Page',
|
||||
value: 'page',
|
||||
@@ -40,6 +45,7 @@ export const confluenceNodeDescription: INodeTypeDescription = {
|
||||
],
|
||||
default: 'page',
|
||||
},
|
||||
...attachment.description,
|
||||
...page.description,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import * as attachment from './attachment';
|
||||
import * as page from './page';
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,12 @@ export type ConfluenceOperation = (
|
||||
itemIndex: number,
|
||||
) => Promise<IDataObject | IDataObject[]>;
|
||||
|
||||
/** Variant for operations that emit their own execution items (e.g. binary output). */
|
||||
export type ConfluenceBinaryOperation = (
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
) => Promise<INodeExecutionData[]>;
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
const resource = this.getNodeParameter('resource', 0, '');
|
||||
@@ -21,9 +28,13 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
try {
|
||||
let responseData: IDataObject | IDataObject[];
|
||||
let responseData: IDataObject | IDataObject[] | undefined;
|
||||
let responseItems: INodeExecutionData[] | undefined;
|
||||
|
||||
switch (`${resource}:${operation}`) {
|
||||
case 'attachment:getMany':
|
||||
responseItems = await attachment.getMany.execute.call(this, i);
|
||||
break;
|
||||
case 'page:append':
|
||||
responseData = await page.append.execute.call(this, i);
|
||||
break;
|
||||
@@ -47,7 +58,7 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
|
||||
}
|
||||
|
||||
const executionData = this.helpers.constructExecutionMetaData(
|
||||
this.helpers.returnJsonArray(responseData),
|
||||
responseItems ?? this.helpers.returnJsonArray(responseData ?? []),
|
||||
{ itemData: { item: i } },
|
||||
);
|
||||
returnData.push.apply(returnData, executionData);
|
||||
|
||||
@@ -13,12 +13,20 @@ describe('Confluence Node', () => {
|
||||
expect(node.description.usableAsTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expose the page resource with the append, create, delete, get and update operations', () => {
|
||||
it('should expose the attachment and page resources with their operations', () => {
|
||||
const resource = node.description.properties.find((p) => p.name === 'resource');
|
||||
expect(resource?.options).toEqual([expect.objectContaining({ value: 'page' })]);
|
||||
expect(resource?.options).toEqual([
|
||||
expect.objectContaining({ value: 'attachment' }),
|
||||
expect.objectContaining({ value: 'page' }),
|
||||
]);
|
||||
|
||||
const operation = node.description.properties.find((p) => p.name === 'operation');
|
||||
expect(operation?.options).toEqual([
|
||||
const operations = node.description.properties.filter((p) => p.name === 'operation');
|
||||
const operationsFor = (resourceName: string) =>
|
||||
operations.find((p) => (p.displayOptions?.show?.resource ?? []).includes(resourceName));
|
||||
expect(operationsFor('attachment')?.options).toEqual([
|
||||
expect.objectContaining({ value: 'getMany' }),
|
||||
]);
|
||||
expect(operationsFor('page')?.options).toEqual([
|
||||
expect.objectContaining({ value: 'append' }),
|
||||
expect.objectContaining({ value: 'create' }),
|
||||
expect.objectContaining({ value: 'delete' }),
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import type { IBinaryData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { description, execute } from '../../../actions/attachment/getMany.operation';
|
||||
import { confluenceApiRequest, confluenceApiRequestBinary } from '../../../transport';
|
||||
import { mockExecuteCtx } from '../../shared';
|
||||
|
||||
vi.mock('../../../transport', () => ({
|
||||
CONFLUENCE_CREDENTIAL_NAME: 'confluenceCloudOAuth2Api',
|
||||
confluenceApiRequest: vi.fn(),
|
||||
confluenceApiRequestBinary: vi.fn(),
|
||||
}));
|
||||
|
||||
const apiRequest = vi.mocked(confluenceApiRequest);
|
||||
const binaryRequest = vi.mocked(confluenceApiRequestBinary);
|
||||
|
||||
const ENDPOINT = '/wiki/api/v2/pages/9/attachments';
|
||||
|
||||
const baseParams: Record<string, unknown> = {
|
||||
resource: 'attachment',
|
||||
operation: 'getMany',
|
||||
page: { mode: 'id', value: '9' },
|
||||
returnAll: false,
|
||||
limit: 100,
|
||||
download: false,
|
||||
};
|
||||
|
||||
function attachmentPage(records: Array<Record<string, unknown>>, next?: string) {
|
||||
return {
|
||||
results: records,
|
||||
...(next === undefined ? {} : { _links: { next } }),
|
||||
};
|
||||
}
|
||||
|
||||
async function runGetMany(overrides: Record<string, unknown> = {}) {
|
||||
const ctx = mockExecuteCtx({ ...baseParams, ...overrides });
|
||||
ctx.helpers.prepareBinaryData.mockImplementation(
|
||||
async (buffer, fileName, mimeType): Promise<IBinaryData> => ({
|
||||
data: (buffer as Buffer).toString('base64'),
|
||||
fileName,
|
||||
mimeType: mimeType ?? 'application/octet-stream',
|
||||
}),
|
||||
);
|
||||
return await execute.call(ctx, 0);
|
||||
}
|
||||
|
||||
describe('attachment:getMany', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiRequest.mockResolvedValue(attachmentPage([]));
|
||||
binaryRequest.mockResolvedValue(Buffer.from('file-bytes'));
|
||||
});
|
||||
|
||||
it('keeps the field-specific display conditions alongside the operation scoping', () => {
|
||||
const limitProperty = description.find((p) => p.name === 'limit');
|
||||
expect(limitProperty?.displayOptions?.show).toEqual({
|
||||
resource: ['attachment'],
|
||||
operation: ['getMany'],
|
||||
returnAll: [false],
|
||||
});
|
||||
|
||||
const binaryProperty = description.find((p) => p.name === 'binaryPropertyName');
|
||||
expect(binaryProperty?.displayOptions?.show).toEqual({
|
||||
resource: ['attachment'],
|
||||
operation: ['getMany'],
|
||||
download: [true],
|
||||
});
|
||||
});
|
||||
|
||||
it('lists attachments and returns one item per record', async () => {
|
||||
apiRequest.mockResolvedValue(
|
||||
attachmentPage([
|
||||
{ id: 'a1', title: 'notes.txt', mediaType: 'text/plain', fileSize: 5 },
|
||||
{ id: 'a2', title: 'plan.pdf', mediaType: 'application/pdf', fileSize: 9 },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runGetMany();
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(apiRequest).toHaveBeenCalledWith('GET', ENDPOINT, {}, { limit: 100 });
|
||||
expect(result).toEqual([
|
||||
{ json: { id: 'a1', title: 'notes.txt', mediaType: 'text/plain', fileSize: 5 } },
|
||||
{ json: { id: 'a2', title: 'plan.pdf', mediaType: 'application/pdf', fileSize: 9 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('paginates with the next cursor until exhausted', async () => {
|
||||
apiRequest
|
||||
.mockResolvedValueOnce(attachmentPage([{ id: 'a1' }], `${ENDPOINT}?cursor=c1`))
|
||||
.mockResolvedValueOnce(attachmentPage([{ id: 'a2' }]));
|
||||
|
||||
const result = await runGetMany({ returnAll: true });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequest).toHaveBeenNthCalledWith(1, 'GET', ENDPOINT, {}, { limit: 250 });
|
||||
expect(apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'GET',
|
||||
ENDPOINT,
|
||||
{},
|
||||
{ limit: 250, cursor: 'c1' },
|
||||
);
|
||||
expect(result).toEqual([{ json: { id: 'a1' } }, { json: { id: 'a2' } }]);
|
||||
});
|
||||
|
||||
it('stops fetching once the limit is met and truncates', async () => {
|
||||
apiRequest.mockResolvedValue(
|
||||
attachmentPage([{ id: 'a1' }, { id: 'a2' }], `${ENDPOINT}?cursor=c1`),
|
||||
);
|
||||
|
||||
const result = await runGetMany({ limit: 1 });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(apiRequest).toHaveBeenCalledWith('GET', ENDPOINT, {}, { limit: 1 });
|
||||
expect(result).toEqual([{ json: { id: 'a1' } }]);
|
||||
});
|
||||
|
||||
it('stops when the next cursor revisits an earlier page', async () => {
|
||||
apiRequest.mockResolvedValue(attachmentPage([{ id: 'a1' }], `${ENDPOINT}?cursor=same`));
|
||||
|
||||
await runGetMany({ returnAll: true });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a non-positive limit from an expression', async () => {
|
||||
const promise = runGetMany({ limit: 0 });
|
||||
|
||||
await expect(promise).rejects.toThrow('Limit must be a number of at least 1');
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads each attachment into the binary field', async () => {
|
||||
apiRequest.mockResolvedValue(
|
||||
attachmentPage([
|
||||
{
|
||||
id: 'a1',
|
||||
title: 'notes.txt',
|
||||
mediaType: 'text/plain',
|
||||
downloadLink: '/download/attachments/9/notes.txt?version=1&api=v2',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runGetMany({ download: true });
|
||||
|
||||
expect(binaryRequest).toHaveBeenCalledWith(
|
||||
'/wiki/download/attachments/9/notes.txt?version=1&api=v2',
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
json: expect.objectContaining({ id: 'a1' }),
|
||||
binary: {
|
||||
data: {
|
||||
data: Buffer.from('file-bytes').toString('base64'),
|
||||
fileName: 'notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('percent-encodes raw filename characters while keeping the query string intact', async () => {
|
||||
apiRequest.mockResolvedValue(
|
||||
attachmentPage([
|
||||
{
|
||||
id: 'a1',
|
||||
title: 'report #3.pdf',
|
||||
downloadLink: '/download/attachments/9/report #3.pdf?version=1&api=v2',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await runGetMany({ download: true });
|
||||
|
||||
expect(binaryRequest).toHaveBeenCalledWith(
|
||||
'/wiki/download/attachments/9/report%20%233.pdf?version=1&api=v2',
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads only the retained attachments after truncation, each into its own binary', async () => {
|
||||
apiRequest.mockResolvedValue(
|
||||
attachmentPage([
|
||||
{ id: 'a1', downloadLink: '/download/a1' },
|
||||
{ id: 'a2', downloadLink: '/download/a2' },
|
||||
{ id: 'a3' },
|
||||
]),
|
||||
);
|
||||
binaryRequest.mockImplementation(async (endpoint) => Buffer.from(endpoint));
|
||||
|
||||
const result = await runGetMany({ download: true, limit: 2 });
|
||||
|
||||
expect(binaryRequest).toHaveBeenCalledTimes(2);
|
||||
expect(binaryRequest).toHaveBeenNthCalledWith(1, '/wiki/download/a1');
|
||||
expect(binaryRequest).toHaveBeenNthCalledWith(2, '/wiki/download/a2');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].binary?.data.data).toBe(Buffer.from('/wiki/download/a1').toString('base64'));
|
||||
expect(result[1].binary?.data.data).toBe(Buffer.from('/wiki/download/a2').toString('base64'));
|
||||
});
|
||||
|
||||
it('keeps decrementing the page limit while following cursors under a finite limit', async () => {
|
||||
const fullPage = Array.from({ length: 250 }, (_, i) => ({ id: `r${i}` }));
|
||||
apiRequest
|
||||
.mockResolvedValueOnce(attachmentPage(fullPage, `${ENDPOINT}?cursor=c1`))
|
||||
.mockResolvedValueOnce(
|
||||
attachmentPage(Array.from({ length: 50 }, (_, i) => ({ id: `s${i}` }))),
|
||||
);
|
||||
|
||||
const result = await runGetMany({ limit: 300 });
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequest).toHaveBeenNthCalledWith(2, 'GET', ENDPOINT, {}, { limit: 50, cursor: 'c1' });
|
||||
expect(result).toHaveLength(300);
|
||||
});
|
||||
|
||||
it('puts the file in a custom binary field', async () => {
|
||||
apiRequest.mockResolvedValue(
|
||||
attachmentPage([{ id: 'a1', downloadLink: '/download/attachments/9/a' }]),
|
||||
);
|
||||
|
||||
const result = await runGetMany({ download: true, binaryPropertyName: 'file' });
|
||||
|
||||
expect(result[0].binary).toHaveProperty('file');
|
||||
expect(result[0].binary).not.toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('throws when an attachment has no usable download link', async () => {
|
||||
apiRequest.mockResolvedValue(attachmentPage([{ id: 'a1', title: 'notes.txt' }]));
|
||||
|
||||
const promise = runGetMany({ download: true });
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('"notes.txt" has no usable download link');
|
||||
expect(binaryRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a download link that is not server-relative', async () => {
|
||||
apiRequest.mockResolvedValue(
|
||||
attachmentPage([
|
||||
{ id: 'a1', title: 'notes.txt', downloadLink: 'https://elsewhere.example/f.txt' },
|
||||
]),
|
||||
);
|
||||
|
||||
const promise = runGetMany({ download: true });
|
||||
|
||||
await expect(promise).rejects.toThrow('"notes.txt" has no usable download link');
|
||||
expect(binaryRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,28 @@ describe('Confluence router', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches attachment:getMany and pairs the emitted items', async () => {
|
||||
apiRequest.mockResolvedValue({ results: [{ id: 'a1', title: 'notes.txt' }] });
|
||||
|
||||
const result = await router.call(
|
||||
mockExecuteCtx({
|
||||
resource: 'attachment',
|
||||
operation: 'getMany',
|
||||
page: { mode: 'id', value: '9' },
|
||||
returnAll: true,
|
||||
download: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/api/v2/pages/9/attachments',
|
||||
{},
|
||||
{ limit: 250 },
|
||||
);
|
||||
expect(result).toEqual([[{ json: { id: 'a1', title: 'notes.txt' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('dispatches page:delete and returns the deletion report', async () => {
|
||||
const result = await router.call(
|
||||
mockExecuteCtx({
|
||||
|
||||
@@ -1,22 +1,58 @@
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INode, JsonObject } from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { clearAtlassianCloudIdCache } from '@utils/atlassian';
|
||||
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
import { confluenceApiRequest, confluenceApiRequestBinary } from '../../transport';
|
||||
|
||||
const accessibleResources = [
|
||||
{ id: 'cloud-1', url: 'https://example.atlassian.net', name: 'example' },
|
||||
{ id: 'cloud-2', url: 'https://Other.Atlassian.NET' },
|
||||
];
|
||||
|
||||
const pageNotFoundResponse = {
|
||||
message: 'Request failed with status code 404',
|
||||
response: {
|
||||
status: 404,
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
title: 'Page not found',
|
||||
detail: 'No page with this ID exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('confluenceApiRequest', () => {
|
||||
let ctx: Mocked<IExecuteFunctions>;
|
||||
let mockNode: INode;
|
||||
let mockHttpRequestWithAuthentication: Mock;
|
||||
|
||||
async function captureRejection(endpoint: string): Promise<NodeApiError | null> {
|
||||
return await confluenceApiRequest
|
||||
.call(ctx, 'GET', endpoint)
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
}
|
||||
|
||||
function failNextRequest(error: unknown): void {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce(error);
|
||||
}
|
||||
|
||||
function failNextRequestWrapped(payload: JsonObject): NodeApiError {
|
||||
const wrapped = new NodeApiError(mockNode, payload);
|
||||
failNextRequest(wrapped);
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearAtlassianCloudIdCache();
|
||||
@@ -94,14 +130,9 @@ describe('confluenceApiRequest', () => {
|
||||
});
|
||||
|
||||
it('wraps request failures in NodeApiError, keeping status and message', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({ message: 'boom', response: { status: 403 } });
|
||||
failNextRequest({ message: 'boom', response: { status: 403 } });
|
||||
|
||||
const error = await confluenceApiRequest
|
||||
.call(ctx, 'GET', '/wiki/api/v2/pages')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
const error = await captureRejection('/wiki/api/v2/pages');
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('403');
|
||||
@@ -109,29 +140,9 @@ describe('confluenceApiRequest', () => {
|
||||
});
|
||||
|
||||
it("surfaces Atlassian's v2 error envelope instead of the generic status message", async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({
|
||||
message: 'Request failed with status code 404',
|
||||
response: {
|
||||
status: 404,
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
title: 'Page not found',
|
||||
detail: 'No page with this ID exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
failNextRequest(pageNotFoundResponse);
|
||||
|
||||
const error = await confluenceApiRequest
|
||||
.call(ctx, 'GET', '/wiki/api/v2/pages/1')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
const error = await captureRejection('/wiki/api/v2/pages/1');
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.message).toBe('Page not found');
|
||||
@@ -139,23 +150,65 @@ describe('confluenceApiRequest', () => {
|
||||
});
|
||||
|
||||
it('falls back to the generic wrap when the envelope carries no usable title', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({
|
||||
message: 'boom',
|
||||
response: { status: 500, data: { errors: [{ title: '' }] } },
|
||||
});
|
||||
failNextRequest({
|
||||
message: 'boom',
|
||||
response: { status: 500, data: { errors: [{ title: '' }] } },
|
||||
});
|
||||
|
||||
const error = await confluenceApiRequest
|
||||
.call(ctx, 'GET', '/wiki/api/v2/pages')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
const error = await captureRejection('/wiki/api/v2/pages');
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('500');
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
// In production the request helper rejects with an already-wrapped NodeApiError; these cases pin that path
|
||||
it('surfaces the v1 top-level message from a wrapped NodeApiError', async () => {
|
||||
const wrapped = failNextRequestWrapped({
|
||||
message: 'Request failed with status code 400',
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
statusCode: 400,
|
||||
data: { authorized: true, valid: false, errors: [], successful: false },
|
||||
message: 'Could not parse cql : expecting alphanumeric',
|
||||
},
|
||||
},
|
||||
} as JsonObject);
|
||||
|
||||
const error = await captureRejection('/wiki/rest/api/search');
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error).not.toBe(wrapped);
|
||||
expect(error?.message).toBe('Could not parse cql : expecting alphanumeric');
|
||||
expect(error?.httpCode).toBe('400');
|
||||
expect(error?.context.data).toEqual({
|
||||
statusCode: 400,
|
||||
data: { authorized: true, valid: false, errors: [], successful: false },
|
||||
message: 'Could not parse cql : expecting alphanumeric',
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces Atlassian's v2 envelope from a wrapped NodeApiError", async () => {
|
||||
const wrapped = failNextRequestWrapped(pageNotFoundResponse as JsonObject);
|
||||
|
||||
const error = await captureRejection('/wiki/api/v2/pages/1');
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error).not.toBe(wrapped);
|
||||
expect(error?.message).toBe('Page not found');
|
||||
expect(error?.description).toBe('No page with this ID exists');
|
||||
expect(error?.httpCode).toBe('404');
|
||||
});
|
||||
|
||||
it('rethrows a wrapped NodeApiError unchanged when there is no response body', async () => {
|
||||
const wrapped = failNextRequestWrapped({ message: 'socket hang up' } as JsonObject);
|
||||
|
||||
const error = await captureRejection('/wiki/api/v2/pages');
|
||||
|
||||
expect(error).toBe(wrapped);
|
||||
});
|
||||
|
||||
it('surfaces the cloudId lookup error when no site matches', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({ domain: 'https://missing.atlassian.net' });
|
||||
|
||||
@@ -174,3 +227,123 @@ describe('confluenceApiRequest', () => {
|
||||
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('confluenceApiRequestBinary', () => {
|
||||
let ctx: Mocked<IExecuteFunctions>;
|
||||
let mockHttpRequestWithAuthentication: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearAtlassianCloudIdCache();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
mockHttpRequestWithAuthentication = vi.fn().mockResolvedValue(accessibleResources);
|
||||
ctx.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
|
||||
ctx.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Confluence Node',
|
||||
type: 'n8n-nodes-base.confluence',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
ctx.getCredentials.mockResolvedValue({ domain: 'https://example.atlassian.net/wiki' });
|
||||
});
|
||||
|
||||
it('fetches the endpoint through the gateway as a Buffer', async () => {
|
||||
const bytes = Buffer.from('file-bytes');
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockResolvedValueOnce(bytes);
|
||||
|
||||
const data = await confluenceApiRequestBinary.call(ctx, '/wiki/download/attachments/9/a.txt');
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
url: 'https://api.atlassian.com/ex/confluence/cloud-1/wiki/download/attachments/9/a.txt',
|
||||
encoding: 'arraybuffer',
|
||||
sendCredentialsOnCrossOriginRedirect: false,
|
||||
}),
|
||||
);
|
||||
expect(data).toBe(bytes);
|
||||
});
|
||||
|
||||
it('coerces non-Buffer binary responses to a Buffer', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockResolvedValueOnce('plain-text-body');
|
||||
|
||||
const data = await confluenceApiRequestBinary.call(ctx, '/wiki/download/attachments/9/a.txt');
|
||||
|
||||
expect(Buffer.isBuffer(data)).toBe(true);
|
||||
expect(data.toString()).toBe('plain-text-body');
|
||||
});
|
||||
|
||||
it('coerces an ArrayBuffer response to a Buffer', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockResolvedValueOnce(new TextEncoder().encode('ab-bytes').buffer);
|
||||
|
||||
const data = await confluenceApiRequestBinary.call(ctx, '/wiki/download/attachments/9/a.txt');
|
||||
|
||||
expect(Buffer.isBuffer(data)).toBe(true);
|
||||
expect(data.toString()).toBe('ab-bytes');
|
||||
});
|
||||
|
||||
it('rejects an unusable binary response with a NodeOperationError', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockResolvedValueOnce({ unexpected: true });
|
||||
|
||||
await expect(
|
||||
confluenceApiRequestBinary.call(ctx, '/wiki/download/attachments/9/a.txt'),
|
||||
).rejects.toThrow('Confluence returned an unexpected binary response');
|
||||
});
|
||||
|
||||
it('wraps request failures in NodeApiError, keeping the status', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({ message: 'boom', response: { status: 404 } });
|
||||
|
||||
const error = await confluenceApiRequestBinary
|
||||
.call(ctx, '/wiki/download/attachments/9/a.txt')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('404');
|
||||
});
|
||||
|
||||
it("surfaces Atlassian's v2 error envelope on download failures", async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({
|
||||
message: 'Request failed with status code 404',
|
||||
response: {
|
||||
status: 404,
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
title: 'Attachment not found',
|
||||
detail: 'No attachment with this ID exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const error = await confluenceApiRequestBinary
|
||||
.call(ctx, '/wiki/download/attachments/9/a.txt')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.message).toBe('Attachment not found');
|
||||
expect(error?.description).toBe('No attachment with this ID exists');
|
||||
expect(error?.httpCode).toBe('404');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,75 @@ import { getAtlassianApiBaseUrl, getAtlassianCloudId } from '@utils/atlassian';
|
||||
|
||||
export const CONFLUENCE_CREDENTIAL_NAME = 'confluenceCloudOAuth2Api';
|
||||
|
||||
interface CaughtRequestError {
|
||||
response?: { status?: unknown; data?: unknown };
|
||||
}
|
||||
|
||||
interface ExtractedApiError {
|
||||
message: string;
|
||||
description?: string;
|
||||
data: IDataObject;
|
||||
}
|
||||
|
||||
function extractApiMessage(body: unknown): ExtractedApiError | undefined {
|
||||
if (typeof body !== 'object' || body === null) return undefined;
|
||||
const data = body as IDataObject;
|
||||
const { errors: v2Errors, message: v1Message } = data as {
|
||||
errors?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
const first = Array.isArray(v2Errors)
|
||||
? (v2Errors[0] as { title?: unknown; detail?: unknown } | undefined)
|
||||
: undefined;
|
||||
if (typeof first?.title === 'string' && first.title !== '') {
|
||||
return {
|
||||
message: first.title,
|
||||
description:
|
||||
typeof first.detail === 'string' && first.detail !== '' ? first.detail : undefined,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof v1Message === 'string' && v1Message !== '') return { message: v1Message, data };
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// NodeApiError's constructor short-circuits on re-wrap (returns the same
|
||||
// instance, dropping any option overrides), so enrichment needs a fresh error.
|
||||
function toConfluenceApiError(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
error: unknown,
|
||||
): NodeApiError {
|
||||
const wrapped = error instanceof NodeApiError ? error : undefined;
|
||||
const body = wrapped ? wrapped.context.data : (error as CaughtRequestError).response?.data;
|
||||
|
||||
const extracted = extractApiMessage(body);
|
||||
if (extracted !== undefined) {
|
||||
let httpCode: string | undefined;
|
||||
if (wrapped) {
|
||||
httpCode = wrapped.httpCode ?? undefined;
|
||||
} else {
|
||||
const status = (error as CaughtRequestError).response?.status;
|
||||
httpCode =
|
||||
typeof status === 'number' || typeof status === 'string' ? String(status) : undefined;
|
||||
}
|
||||
|
||||
const sanitizedError: JsonObject = { message: extracted.message };
|
||||
const fresh = new NodeApiError(this.getNode(), sanitizedError, {
|
||||
message: extracted.message,
|
||||
description: extracted.description,
|
||||
httpCode,
|
||||
});
|
||||
// Keep the raw response body visible in the NDV's error-data pane
|
||||
fresh.context.data = extracted.data;
|
||||
return fresh;
|
||||
}
|
||||
if (wrapped) return wrapped;
|
||||
return new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
|
||||
export async function confluenceApiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
@@ -52,20 +121,56 @@ export async function confluenceApiRequest(
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
// Atlassian's v2 error envelope sits in response.data.errors; without this,
|
||||
// NodeApiError stops at Axios's generic "Request failed with status code N"
|
||||
const envelope = (error as { response?: { data?: { errors?: unknown } } }).response?.data
|
||||
?.errors;
|
||||
const first = Array.isArray(envelope)
|
||||
? (envelope[0] as { title?: unknown; detail?: unknown })
|
||||
: undefined;
|
||||
const title = typeof first?.title === 'string' && first.title !== '' ? first.title : undefined;
|
||||
const detail =
|
||||
typeof first?.detail === 'string' && first.detail !== '' ? first.detail : undefined;
|
||||
throw new NodeApiError(
|
||||
this.getNode(),
|
||||
error as JsonObject,
|
||||
title ? { message: title, description: detail } : undefined,
|
||||
);
|
||||
throw toConfluenceApiError.call(this, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a binary resource (e.g. an attachment's server-relative `downloadLink`)
|
||||
* through the gateway and returns its raw bytes. Same base-URL concatenation rule
|
||||
* as `confluenceApiRequest`: the endpoint can never change the host.
|
||||
*/
|
||||
export async function confluenceApiRequestBinary(
|
||||
this: IExecuteFunctions,
|
||||
endpoint: string,
|
||||
): Promise<Buffer> {
|
||||
const credentials = await this.getCredentials(CONFLUENCE_CREDENTIAL_NAME);
|
||||
const siteUrl = credentials.domain;
|
||||
if (typeof siteUrl !== 'string' || siteUrl === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'The Confluence credential is missing the Site URL field',
|
||||
);
|
||||
}
|
||||
const cloudId = await getAtlassianCloudId.call(
|
||||
this,
|
||||
CONFLUENCE_CREDENTIAL_NAME,
|
||||
siteUrl,
|
||||
'confluence',
|
||||
);
|
||||
|
||||
// Downloads 302 to the Atlassian media host, which authenticates the hop via its
|
||||
// own signed token in the redirect URL; the OAuth header must not follow cross-origin.
|
||||
const options: IHttpRequestOptions = {
|
||||
method: 'GET',
|
||||
url: `${getAtlassianApiBaseUrl('confluence', cloudId)}${endpoint}`,
|
||||
encoding: 'arraybuffer',
|
||||
sendCredentialsOnCrossOriginRedirect: false,
|
||||
};
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await this.helpers.httpRequestWithAuthentication.call(
|
||||
this,
|
||||
CONFLUENCE_CREDENTIAL_NAME,
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
throw toConfluenceApiError.call(this, error);
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(data)) return data;
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
||||
if (typeof data === 'string') return Buffer.from(data);
|
||||
throw new NodeOperationError(this.getNode(), 'Confluence returned an unexpected binary response');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user