mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(Confluence Node): Add node scaffolding (hidden shell + request helper) (no-changelog) (#35688)
This commit is contained in:
parent
9241a72b9a
commit
f6caf53830
18
packages/nodes-base/nodes/Confluence/Confluence.node.json
Normal file
18
packages/nodes-base/nodes/Confluence/Confluence.node.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"node": "n8n-nodes-base.confluence",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Productivity"],
|
||||
"resources": {
|
||||
"credentialDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/credentials/confluence/"
|
||||
}
|
||||
],
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.confluence/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
12
packages/nodes-base/nodes/Confluence/Confluence.node.ts
Normal file
12
packages/nodes-base/nodes/Confluence/Confluence.node.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { confluenceNodeDescription } from './actions/description';
|
||||
import { router } from './actions/router';
|
||||
|
||||
export class Confluence implements INodeType {
|
||||
description: INodeTypeDescription = confluenceNodeDescription;
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
}
|
||||
28
packages/nodes-base/nodes/Confluence/actions/description.ts
Normal file
28
packages/nodes-base/nodes/Confluence/actions/description.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { CONFLUENCE_CREDENTIAL_NAME } from '../transport';
|
||||
|
||||
export const confluenceNodeDescription: INodeTypeDescription = {
|
||||
displayName: 'Confluence',
|
||||
name: 'confluence',
|
||||
icon: 'file:confluence.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Interact with the Confluence Cloud API',
|
||||
defaults: {
|
||||
name: 'Confluence',
|
||||
},
|
||||
// Hidden shell: operations land per-ticket; unhide + usableAsTool + docs at ENT-311
|
||||
hidden: true,
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
credentials: [
|
||||
{
|
||||
name: CONFLUENCE_CREDENTIAL_NAME,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
27
packages/nodes-base/nodes/Confluence/actions/router.ts
Normal file
27
packages/nodes-base/nodes/Confluence/actions/router.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Compile-checked contract for operation modules. The router body (owned by ENT-125)
|
||||
* calls `<resource>.<operation>.execute.call(this, i)` once per item, SharePoint v2
|
||||
* shape; the other op tickets (ENT-126/319/127/305/327/306) implement against this.
|
||||
*/
|
||||
export type ConfluenceOperation = (
|
||||
this: IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
) => Promise<IDataObject | IDataObject[]>;
|
||||
|
||||
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
// Fallbacks: the shell ships properties: [], so these parameters don't exist yet
|
||||
const resource = this.getNodeParameter('resource', 0, '');
|
||||
const operation = this.getNodeParameter('operation', 0, '');
|
||||
|
||||
switch (resource) {
|
||||
// Op tickets (ENT-125/126/319/127/305/327/306) add their resource cases here
|
||||
default:
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The operation "${resource}:${operation}" is not supported`,
|
||||
);
|
||||
}
|
||||
}
|
||||
1
packages/nodes-base/nodes/Confluence/confluence.svg
Normal file
1
packages/nodes-base/nodes/Confluence/confluence.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 246"><defs><linearGradient id="a" x1="99.14%" x2="33.859%" y1="112.708%" y2="37.755%"><stop offset="18%" stop-color="#0052cc"/><stop offset="100%" stop-color="#2684ff"/></linearGradient><linearGradient id="b" x1=".926%" x2="66.18%" y1="-12.582%" y2="62.306%"><stop offset="18%" stop-color="#0052cc"/><stop offset="100%" stop-color="#2684ff"/></linearGradient></defs><path fill="url(#a)" d="M9.26 187.33c-2.64 4.307-5.607 9.305-8.126 13.287a8.145 8.145 0 0 0 2.722 11.052l52.823 32.506a8.145 8.145 0 0 0 11.255-2.763c2.113-3.535 4.836-8.126 7.802-13.043 20.926-34.538 41.974-30.312 79.925-12.19l52.376 24.908a8.145 8.145 0 0 0 10.93-4.063l25.152-56.886a8.145 8.145 0 0 0-4.063-10.646c-11.052-5.201-33.035-15.562-52.823-25.111-71.189-34.579-131.692-32.344-177.973 42.95Z"/><path fill="url(#b)" d="M246.74 58.67c2.64-4.307 5.607-9.305 8.126-13.287a8.145 8.145 0 0 0-2.722-11.052L199.321 1.825a8.145 8.145 0 0 0-11.255 2.763c-2.113 3.535-4.836 8.126-7.802 13.043-20.926 34.538-41.974 30.312-79.925 12.19L47.963 4.913a8.145 8.145 0 0 0-10.93 4.063L11.881 65.862a8.145 8.145 0 0 0 4.063 10.646c11.052 5.201 33.035 15.562 52.823 25.111 71.189 34.579 131.692 32.344 177.973-42.95Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,48 @@
|
|||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { Confluence } from '../Confluence.node';
|
||||
|
||||
describe('Confluence Node', () => {
|
||||
const node = new Confluence();
|
||||
|
||||
it('should ship gated: hidden, no properties, not usable as a tool', () => {
|
||||
expect(node.description.hidden).toBe(true);
|
||||
expect(node.description.properties).toEqual([]);
|
||||
expect(node.description.usableAsTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reference the confluenceCloudOAuth2Api credential by name', () => {
|
||||
expect(node.description.credentials).toEqual([
|
||||
{ name: 'confluenceCloudOAuth2Api', required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should register under the confluence name', () => {
|
||||
expect(node.description.name).toBe('confluence');
|
||||
expect(node.description.displayName).toBe('Confluence');
|
||||
expect(node.description.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw a NodeOperationError when executed without an operation', async () => {
|
||||
const ctx = mockDeep<IExecuteFunctions>();
|
||||
ctx.getInputData.mockReturnValue([{ json: {} }]);
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(_name: string, _itemIndex?: number, fallback?: unknown) => fallback as never,
|
||||
);
|
||||
ctx.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Confluence Node',
|
||||
type: 'n8n-nodes-base.confluence',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
const promise = node.execute.call(ctx);
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('The operation ":" is not supported');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import type { AccessibleResource } from '../../transport';
|
||||
import {
|
||||
clearCloudIdCache,
|
||||
confluenceApiRequest,
|
||||
getConfluenceCloudId,
|
||||
matchSiteByHostname,
|
||||
normalizeSiteUrl,
|
||||
} from '../../transport';
|
||||
|
||||
const accessibleResources: AccessibleResource[] = [
|
||||
{ id: 'cloud-1', url: 'https://example.atlassian.net', name: 'example' },
|
||||
{ id: 'cloud-2', url: 'https://Other.Atlassian.NET' },
|
||||
];
|
||||
|
||||
describe('Confluence Transport', () => {
|
||||
let ctx: Mocked<IExecuteFunctions>;
|
||||
let mockNode: INode;
|
||||
let mockHttpRequestWithAuthentication: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearCloudIdCache();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
mockHttpRequestWithAuthentication = vi.fn().mockResolvedValue(accessibleResources);
|
||||
ctx.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
|
||||
|
||||
mockNode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Confluence Node',
|
||||
type: 'n8n-nodes-base.confluence',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
ctx.getCredentials.mockResolvedValue({ domain: 'https://example.atlassian.net/wiki' });
|
||||
});
|
||||
|
||||
describe('normalizeSiteUrl', () => {
|
||||
it.each([
|
||||
'example.atlassian.net',
|
||||
'http://example.atlassian.net',
|
||||
'https://example.atlassian.net/wiki',
|
||||
'https://example.atlassian.net/wiki/spaces/ENG/pages/123',
|
||||
'HTTPS://Example.Atlassian.NET/wiki/',
|
||||
' example.atlassian.net ',
|
||||
])('reduces "%s" to the lowercased hostname', (input) => {
|
||||
expect(normalizeSiteUrl(input)).toBe('example.atlassian.net');
|
||||
});
|
||||
|
||||
it('throws on an unparseable site URL', () => {
|
||||
expect(() => normalizeSiteUrl('not a url')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchSiteByHostname', () => {
|
||||
it('matches case-insensitively on both sides', () => {
|
||||
expect(matchSiteByHostname(accessibleResources, 'OTHER.atlassian.net')).toBe(
|
||||
accessibleResources[1],
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when nothing matches', () => {
|
||||
expect(matchSiteByHostname(accessibleResources, 'missing.atlassian.net')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips entries with malformed URLs instead of throwing', () => {
|
||||
const withMalformed: AccessibleResource[] = [
|
||||
{ id: 'bad', url: 'not a url' },
|
||||
...accessibleResources,
|
||||
];
|
||||
expect(matchSiteByHostname(withMalformed, 'example.atlassian.net')).toBe(
|
||||
accessibleResources[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfluenceCloudId', () => {
|
||||
it('resolves the cloudId by case-insensitive hostname match', async () => {
|
||||
const cloudId = await getConfluenceCloudId.call(ctx, 'OTHER.atlassian.net/wiki');
|
||||
|
||||
expect(cloudId).toBe('cloud-2');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('serves the second lookup from the cache without refetching', async () => {
|
||||
const first = await getConfluenceCloudId.call(ctx, 'https://example.atlassian.net/wiki');
|
||||
const second = await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
|
||||
|
||||
expect(first).toBe('cloud-1');
|
||||
expect(second).toBe('cloud-1');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not share the cache across credentials', async () => {
|
||||
ctx.getNode.mockReturnValue({
|
||||
...mockNode,
|
||||
credentials: { confluenceCloudOAuth2Api: { id: 'cred-a', name: 'A' } },
|
||||
});
|
||||
await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
|
||||
|
||||
ctx.getNode.mockReturnValue({
|
||||
...mockNode,
|
||||
credentials: { confluenceCloudOAuth2Api: { id: 'cred-b', name: 'B' } },
|
||||
});
|
||||
await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('names the input and lists reachable site URLs when nothing matches', async () => {
|
||||
const promise = getConfluenceCloudId.call(ctx, 'foo.atlassian.net');
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow(
|
||||
'No Confluence site matched "foo.atlassian.net". This connection can access: https://example.atlassian.net, https://Other.Atlassian.NET',
|
||||
);
|
||||
});
|
||||
|
||||
it('says "no sites" when the connection reaches no Confluence sites', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
|
||||
'can access: no sites',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a non-array accessible-resources body as no sites', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce({ error: 'unexpected' });
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
|
||||
'can access: no sites',
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the reachable-sites list at 5 entries', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce(
|
||||
Array.from({ length: 7 }, (_, i) => ({
|
||||
id: `cloud-${i}`,
|
||||
url: `https://site-${i}.atlassian.net`,
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
|
||||
'This connection can access: https://site-0.atlassian.net, https://site-1.atlassian.net, https://site-2.atlassian.net, https://site-3.atlassian.net, https://site-4.atlassian.net, and 2 more',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits malformed entries from the reachable-sites list', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce([
|
||||
null,
|
||||
{ id: 'no-url' },
|
||||
{ id: 'cloud-1', url: 'https://example.atlassian.net' },
|
||||
]);
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
|
||||
'This connection can access: https://example.atlassian.net',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a NodeOperationError for an unparseable site URL', async () => {
|
||||
await expect(getConfluenceCloudId.call(ctx, 'not a url')).rejects.toThrow(NodeOperationError);
|
||||
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps an accessible-resources failure in NodeApiError, keeping status and message', async () => {
|
||||
mockHttpRequestWithAuthentication.mockRejectedValueOnce({
|
||||
message: 'boom',
|
||||
response: { status: 500 },
|
||||
});
|
||||
|
||||
const error = await getConfluenceCloudId
|
||||
.call(ctx, 'example.atlassian.net')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('500');
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
it('passes an already-wrapped NodeApiError through unchanged', async () => {
|
||||
// The realistic path: httpRequestWithAuthentication rejects with a NodeApiError already
|
||||
const wrapped = new NodeApiError(mockNode, { message: 'boom' }, { httpCode: '429' });
|
||||
mockHttpRequestWithAuthentication.mockRejectedValueOnce(wrapped);
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'example.atlassian.net')).rejects.toBe(wrapped);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confluenceApiRequest', () => {
|
||||
it('routes requests to https://api.atlassian.com/ex/confluence/{cloudId}', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
|
||||
expect(ctx.getCredentials).toHaveBeenCalledWith('confluenceCloudOAuth2Api');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
url: 'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the cached cloudId across requests', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/spaces');
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(3);
|
||||
const urls = mockHttpRequestWithAuthentication.mock.calls.map(([, options]) => options.url);
|
||||
expect(urls).toEqual([
|
||||
'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
|
||||
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/spaces',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes method, body and qs through', async () => {
|
||||
const body = { title: 'New page' };
|
||||
const qs = { limit: 25 };
|
||||
|
||||
await confluenceApiRequest.call(ctx, 'POST', '/wiki/api/v2/pages', body, qs);
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({ method: 'POST', body, qs, json: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults body and qs to empty objects', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({ body: {}, qs: {}, json: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps request failures in NodeApiError, keeping status and message', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({ message: 'boom', response: { status: 403 } });
|
||||
|
||||
const error = await confluenceApiRequest
|
||||
.call(ctx, 'GET', '/wiki/api/v2/pages')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('403');
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
it('throws a NodeOperationError naming the Site URL field when the credential lacks it', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({});
|
||||
|
||||
const promise = confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('Site URL');
|
||||
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
159
packages/nodes-base/nodes/Confluence/transport/index.ts
Normal file
159
packages/nodes-base/nodes/Confluence/transport/index.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IHttpRequestMethods,
|
||||
IHttpRequestOptions,
|
||||
ILoadOptionsFunctions,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
// Owned by ENT-128 (parallel PR): coupled by name only, never import the class
|
||||
export const CONFLUENCE_CREDENTIAL_NAME = 'confluenceCloudOAuth2Api';
|
||||
|
||||
// Bearer-token origin invariant: every request URL is string-concatenated onto this
|
||||
// constant, so no caller input can change the host. Any future verbatim-URL param
|
||||
// (paginator next-link, uri override) needs an origin check first, see the one in
|
||||
// `microsoftApiRequest` (SharePoint v2 transport). User-supplied path segments in
|
||||
// future endpoints must be encodeURIComponent-ed.
|
||||
const ATLASSIAN_API_BASE = 'https://api.atlassian.com';
|
||||
|
||||
export interface AccessibleResource {
|
||||
id: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure hostname matcher, exported so ENT-128's credential Test can reuse the exact
|
||||
* matching this node routes with. Case-insensitive, never an exact-string comparison
|
||||
* (the trap in Jira's `getCloudId`).
|
||||
*/
|
||||
export function matchSiteByHostname(
|
||||
resources: AccessibleResource[],
|
||||
hostname: string,
|
||||
): AccessibleResource | undefined {
|
||||
const target = hostname.toLowerCase();
|
||||
// Non-throwing on malformed entries: this is a reuse contract fed with an unvalidated API response
|
||||
return resources.find((resource) => {
|
||||
try {
|
||||
return new URL(resource.url).hostname.toLowerCase() === target;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Module-level cache: credentialId:hostname → cloudId (`_cloudIdCache` in Jira's
|
||||
// GenericFunctions is the precedent, but keyed per credential here: a shared hostname-only
|
||||
// key makes cold vs warm failures distinguishable, letting one user infer that another
|
||||
// credential on the instance reaches a given site).
|
||||
// Lives for the n8n process; a moved/deleted site needs a restart to re-resolve (same ceiling as Jira).
|
||||
// One asymmetry: a warm-cache hit skips the accessible-resources check, so a credential that
|
||||
// lost access to a site it once resolved fails at the API call with Atlassian's 403.
|
||||
const cloudIdCache = new Map<string, string>();
|
||||
|
||||
/** Test-only escape hatch for the module-level cache. */
|
||||
export function clearCloudIdCache(): void {
|
||||
cloudIdCache.clear();
|
||||
}
|
||||
|
||||
/** Reduce any pasted site-URL form (scheme optional, `/wiki` or deeper paths, any casing) to its lowercased hostname. */
|
||||
export function normalizeSiteUrl(siteUrl: string): string {
|
||||
const trimmed = siteUrl.trim();
|
||||
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
return new URL(withScheme).hostname.toLowerCase();
|
||||
}
|
||||
|
||||
export async function getConfluenceCloudId(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
siteUrl: string,
|
||||
): Promise<string> {
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = normalizeSiteUrl(siteUrl);
|
||||
} catch {
|
||||
throw new NodeOperationError(this.getNode(), `"${siteUrl}" is not a valid Confluence site URL`);
|
||||
}
|
||||
|
||||
const credentialId = this.getNode().credentials?.[CONFLUENCE_CREDENTIAL_NAME]?.id ?? '';
|
||||
const cacheKey = `${credentialId}:${hostname}`;
|
||||
const cached = cloudIdCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
let resources: AccessibleResource[];
|
||||
try {
|
||||
resources = await this.helpers.httpRequestWithAuthentication.call(
|
||||
this,
|
||||
CONFLUENCE_CREDENTIAL_NAME,
|
||||
{
|
||||
method: 'GET',
|
||||
url: `${ATLASSIAN_API_BASE}/oauth/token/accessible-resources`,
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
|
||||
// A non-array body falls through to the no-match error instead of a raw TypeError
|
||||
if (!Array.isArray(resources)) resources = [];
|
||||
|
||||
const site = matchSiteByHostname(resources, hostname);
|
||||
|
||||
if (!site) {
|
||||
// Same guard class as matchSiteByHostname: malformed entries must not break the error message.
|
||||
// Capped at 5 so the error stays readable and a bogus siteUrl cannot dump the full site list
|
||||
// into persisted execution data.
|
||||
const urls = resources
|
||||
.filter((resource) => typeof resource?.url === 'string' && resource.url !== '')
|
||||
.map((resource) => resource.url);
|
||||
const reachable =
|
||||
urls.slice(0, 5).join(', ') + (urls.length > 5 ? `, and ${urls.length - 5} more` : '');
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`No Confluence site matched "${siteUrl}". This connection can access: ${reachable || 'no sites'}`,
|
||||
);
|
||||
}
|
||||
|
||||
cloudIdCache.set(cacheKey, site.id);
|
||||
return site.id;
|
||||
}
|
||||
|
||||
export async function confluenceApiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
endpoint: string,
|
||||
body: IDataObject = {},
|
||||
qs: IDataObject = {},
|
||||
): Promise<IDataObject> {
|
||||
const credentials = await this.getCredentials(CONFLUENCE_CREDENTIAL_NAME);
|
||||
// The key is `domain` (kept from Jira credentials for backwards compatibility)
|
||||
// while the user-facing label is "Site URL"; see AtlassianOAuth2Api (ENT-128)
|
||||
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 getConfluenceCloudId.call(this, siteUrl);
|
||||
|
||||
const options: IHttpRequestOptions = {
|
||||
method,
|
||||
url: `${ATLASSIAN_API_BASE}/ex/confluence/${encodeURIComponent(cloudId)}${endpoint}`,
|
||||
body,
|
||||
qs,
|
||||
json: true,
|
||||
};
|
||||
|
||||
try {
|
||||
return await this.helpers.httpRequestWithAuthentication.call(
|
||||
this,
|
||||
CONFLUENCE_CREDENTIAL_NAME,
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
}
|
||||
|
|
@ -504,6 +504,7 @@
|
|||
"dist/nodes/CoinGecko/CoinGecko.node.js",
|
||||
"dist/nodes/CompareDatasets/CompareDatasets.node.js",
|
||||
"dist/nodes/Compression/Compression.node.js",
|
||||
"dist/nodes/Confluence/Confluence.node.js",
|
||||
"dist/nodes/Contentful/Contentful.node.js",
|
||||
"dist/nodes/ConvertKit/ConvertKit.node.js",
|
||||
"dist/nodes/ConvertKit/ConvertKitTrigger.node.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user