From f6caf53830a4e9b31b657b5a4bca511d3d7299c1 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 7 Aug 2026 15:40:31 +0200 Subject: [PATCH] feat(Confluence Node): Add node scaffolding (hidden shell + request helper) (no-changelog) (#35688) --- .../nodes/Confluence/Confluence.node.json | 18 ++ .../nodes/Confluence/Confluence.node.ts | 12 + .../nodes/Confluence/actions/description.ts | 28 ++ .../nodes/Confluence/actions/router.ts | 27 ++ .../nodes/Confluence/confluence.svg | 1 + .../Confluence/test/Confluence.node.test.ts | 48 +++ .../Confluence/test/transport/index.test.ts | 278 ++++++++++++++++++ .../nodes/Confluence/transport/index.ts | 159 ++++++++++ packages/nodes-base/package.json | 1 + 9 files changed, 572 insertions(+) create mode 100644 packages/nodes-base/nodes/Confluence/Confluence.node.json create mode 100644 packages/nodes-base/nodes/Confluence/Confluence.node.ts create mode 100644 packages/nodes-base/nodes/Confluence/actions/description.ts create mode 100644 packages/nodes-base/nodes/Confluence/actions/router.ts create mode 100644 packages/nodes-base/nodes/Confluence/confluence.svg create mode 100644 packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts create mode 100644 packages/nodes-base/nodes/Confluence/test/transport/index.test.ts create mode 100644 packages/nodes-base/nodes/Confluence/transport/index.ts diff --git a/packages/nodes-base/nodes/Confluence/Confluence.node.json b/packages/nodes-base/nodes/Confluence/Confluence.node.json new file mode 100644 index 00000000000..221cf7369b9 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/Confluence.node.json @@ -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/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Confluence/Confluence.node.ts b/packages/nodes-base/nodes/Confluence/Confluence.node.ts new file mode 100644 index 00000000000..e6b6d65bd4f --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/Confluence.node.ts @@ -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); + } +} diff --git a/packages/nodes-base/nodes/Confluence/actions/description.ts b/packages/nodes-base/nodes/Confluence/actions/description.ts new file mode 100644 index 00000000000..36d62c98eea --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/description.ts @@ -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: [], +}; diff --git a/packages/nodes-base/nodes/Confluence/actions/router.ts b/packages/nodes-base/nodes/Confluence/actions/router.ts new file mode 100644 index 00000000000..4a4cf8920f5 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/router.ts @@ -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 `..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; + +export async function router(this: IExecuteFunctions): Promise { + // 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`, + ); + } +} diff --git a/packages/nodes-base/nodes/Confluence/confluence.svg b/packages/nodes-base/nodes/Confluence/confluence.svg new file mode 100644 index 00000000000..b3eea02296f --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/confluence.svg @@ -0,0 +1 @@ + diff --git a/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts new file mode 100644 index 00000000000..b9ca6224ed5 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts @@ -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(); + 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'); + }); +}); diff --git a/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts b/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts new file mode 100644 index 00000000000..a3514079dc2 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts @@ -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; + let mockNode: INode; + let mockHttpRequestWithAuthentication: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + clearCloudIdCache(); + ctx = mockDeep(); + 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(); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Confluence/transport/index.ts b/packages/nodes-base/nodes/Confluence/transport/index.ts new file mode 100644 index 00000000000..ce1f25757ab --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/transport/index.ts @@ -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(); + +/** 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 { + 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 { + 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); + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a13d912f49c..80d982ef917 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -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",