mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
refactor(core): Extract webhook invocation (no-changelog) (#39452)
This commit is contained in:
@@ -80,6 +80,7 @@ import {
|
||||
handleHostedChatResponse,
|
||||
executeWebhook,
|
||||
_privateGetWebhookErrorMessage,
|
||||
invokeWebhook,
|
||||
} from '../webhook-helpers';
|
||||
import { WebhookService } from '../webhook.service';
|
||||
import type { IWebhookResponseCallbackData, WebhookRequest } from '../webhook.types';
|
||||
@@ -1200,11 +1201,131 @@ const executionContextService = mockInstance(ExecutionContextService);
|
||||
const userRepository = mockInstance(UserRepository);
|
||||
mockInstance(AuthService);
|
||||
mockInstance(EventService);
|
||||
mockInstance(WorkflowStatisticsService);
|
||||
const workflowStatisticsService = mockInstance(WorkflowStatisticsService);
|
||||
|
||||
const WORKFLOW_ID = 'wf-1';
|
||||
const EXECUTION_ID = 'exec-1';
|
||||
|
||||
describe('invokeWebhook', () => {
|
||||
const workflowStartNode = mock<INode>({
|
||||
name: 'Webhook',
|
||||
type: WEBHOOK_NODE_TYPE,
|
||||
typeVersion: 2,
|
||||
parameters: {},
|
||||
});
|
||||
const workflow = mock<Workflow>({ id: WORKFLOW_ID });
|
||||
const webhookData = mock<IWebhookData>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns webhook data and emits the success event', async () => {
|
||||
const webhookResultData = { workflowData: [[{ json: { ok: true } }]] };
|
||||
webhookService.runWebhook.mockResolvedValue(webhookResultData);
|
||||
const responseCallback = vi.fn();
|
||||
|
||||
const result = await invokeWebhook({
|
||||
workflow,
|
||||
webhookData,
|
||||
workflowStartNode,
|
||||
additionalData,
|
||||
executionMode: 'webhook',
|
||||
runExecutionData: undefined,
|
||||
webhookType: 'Webhook',
|
||||
responseCallback,
|
||||
});
|
||||
|
||||
expect(webhookService.runWebhook).toHaveBeenCalledWith(
|
||||
workflow,
|
||||
webhookData,
|
||||
workflowStartNode,
|
||||
additionalData,
|
||||
'webhook',
|
||||
null,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
webhookResultData,
|
||||
didSendResponse: false,
|
||||
runExecutionDataChanges: {},
|
||||
});
|
||||
expect(responseCallback).not.toHaveBeenCalled();
|
||||
expect(workflowStatisticsService.emit).toHaveBeenCalledWith('nodeFetchedData', {
|
||||
workflowId: WORKFLOW_ID,
|
||||
node: workflowStartNode,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'masks an internal error',
|
||||
error: new Error('private details'),
|
||||
expectedMessage: 'Workflow Webhook Error: Workflow could not be started!',
|
||||
},
|
||||
{
|
||||
name: 'exposes a workflow configuration error',
|
||||
error: new WorkflowConfigurationError(workflowStartNode, new Error('bad setup')),
|
||||
expectedMessage: 'bad setup',
|
||||
},
|
||||
])('$name', async ({ error, expectedMessage }) => {
|
||||
webhookService.runWebhook.mockRejectedValue(error);
|
||||
const responseCallback = vi.fn();
|
||||
|
||||
const result = await invokeWebhook({
|
||||
workflow,
|
||||
webhookData,
|
||||
workflowStartNode,
|
||||
additionalData,
|
||||
executionMode: 'webhook',
|
||||
runExecutionData: undefined,
|
||||
webhookType: 'Webhook',
|
||||
responseCallback,
|
||||
});
|
||||
|
||||
expect(responseCallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining(expectedMessage) }),
|
||||
{},
|
||||
);
|
||||
expect(result.didSendResponse).toBe(true);
|
||||
expect(result.webhookResultData).toEqual({
|
||||
noWebhookResponse: true,
|
||||
workflowData: [[{ json: {} }]],
|
||||
});
|
||||
expect(result.runExecutionDataChanges.resultData).toEqual(
|
||||
expect.objectContaining({ lastNodeExecuted: 'Webhook', error: expect.any(Object) }),
|
||||
);
|
||||
expect(workflowStatisticsService.emit).not.toHaveBeenCalledWith(
|
||||
'nodeFetchedData',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes and reports non-error rejections', async () => {
|
||||
webhookService.runWebhook.mockRejectedValue('failure');
|
||||
|
||||
await invokeWebhook({
|
||||
workflow,
|
||||
webhookData,
|
||||
workflowStartNode,
|
||||
additionalData,
|
||||
executionMode: 'webhook',
|
||||
runExecutionData: undefined,
|
||||
webhookType: 'Webhook',
|
||||
responseCallback: vi.fn(),
|
||||
});
|
||||
|
||||
expect(Container.get(ErrorReporter).error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(workflowStatisticsService.emit).not.toHaveBeenCalledWith(
|
||||
'nodeFetchedData',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeWebhook form content type', () => {
|
||||
const runRequest = async (startNode: INode, rawBody = '{') => {
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(
|
||||
|
||||
@@ -114,6 +114,20 @@ const SUPPORTED_RESPONSE_MODES = new Set<WebhookResponseMode>([
|
||||
'hostedChat',
|
||||
]);
|
||||
|
||||
interface WebhookInvocationResult {
|
||||
webhookResultData: IWebhookResponseData;
|
||||
didSendResponse: boolean;
|
||||
runExecutionDataChanges: WebhookExecutionDataChanges;
|
||||
}
|
||||
|
||||
interface WebhookExecutionDataChanges {
|
||||
resultData?: {
|
||||
runData: Record<string, never>;
|
||||
lastNodeExecuted: string;
|
||||
error: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
const deferCleanupUntilStreamEnds = (
|
||||
stream: Readable,
|
||||
res: express.Response,
|
||||
@@ -542,6 +556,80 @@ function shouldEstablishTriggerIdentity(workflowStartNode: INode): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Invokes the webhook node and maps invocation errors to execution data. */
|
||||
export async function invokeWebhook({
|
||||
workflow,
|
||||
webhookData,
|
||||
workflowStartNode,
|
||||
additionalData,
|
||||
executionMode,
|
||||
runExecutionData,
|
||||
webhookType,
|
||||
responseCallback,
|
||||
}: {
|
||||
workflow: Workflow;
|
||||
webhookData: IWebhookData;
|
||||
workflowStartNode: INode;
|
||||
additionalData: IWorkflowExecuteAdditionalData;
|
||||
executionMode: WorkflowExecuteMode;
|
||||
runExecutionData: IRunExecutionData | undefined;
|
||||
webhookType: 'Form' | 'Webhook';
|
||||
responseCallback: (
|
||||
error: Error | null,
|
||||
data: IWebhookResponseCallbackData | WebhookResponse,
|
||||
) => void;
|
||||
}): Promise<WebhookInvocationResult> {
|
||||
try {
|
||||
const webhookResultData = await Container.get(WebhookService).runWebhook(
|
||||
workflow,
|
||||
webhookData,
|
||||
workflowStartNode,
|
||||
additionalData,
|
||||
executionMode,
|
||||
runExecutionData ?? null,
|
||||
);
|
||||
Container.get(WorkflowStatisticsService).emit('nodeFetchedData', {
|
||||
workflowId: workflow.id,
|
||||
node: workflowStartNode,
|
||||
});
|
||||
|
||||
return { webhookResultData, didSendResponse: false, runExecutionDataChanges: {} };
|
||||
} catch (e: unknown) {
|
||||
const error = ensureError(e);
|
||||
const errorMessage = _privateGetWebhookErrorMessage(error, webhookType);
|
||||
|
||||
Container.get(ErrorReporter).error(error, {
|
||||
extra: {
|
||||
nodeName: workflowStartNode.name,
|
||||
nodeType: workflowStartNode.type,
|
||||
nodeVersion: workflowStartNode.typeVersion,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
});
|
||||
|
||||
responseCallback(new UnexpectedError(errorMessage), {});
|
||||
|
||||
return {
|
||||
didSendResponse: true,
|
||||
runExecutionDataChanges: {
|
||||
resultData: {
|
||||
runData: {},
|
||||
lastNodeExecuted: workflowStartNode.name,
|
||||
error: {
|
||||
...error,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
},
|
||||
webhookResultData: {
|
||||
noWebhookResponse: true,
|
||||
workflowData: [[{ json: {} }]],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles a pre-seeded execution stack (identity/context trigger flows) with the
|
||||
* webhook node's real output. No-op unless the start node seeded execution data.
|
||||
@@ -879,10 +967,6 @@ export async function executeWebhook(
|
||||
const engineV2Webhooks = Container.get(EngineV2Webhooks);
|
||||
let cleanupMultipartFiles: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
// Run the webhook function to see what should be returned and if
|
||||
// the workflow should be executed or not
|
||||
let webhookResultData: IWebhookResponseData;
|
||||
|
||||
// Before the node runs: a streaming node, and the chat, MCP and Agent365
|
||||
// triggers, answer the request themselves, so a later refusal could not send
|
||||
// the 400. Also before the request body is parsed, so no file is stored for a
|
||||
@@ -938,59 +1022,19 @@ export async function executeWebhook(
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
webhookResultData = await Container.get(WebhookService).runWebhook(
|
||||
workflow,
|
||||
webhookData,
|
||||
workflowStartNode,
|
||||
additionalData,
|
||||
executionMode,
|
||||
runExecutionData ?? null,
|
||||
);
|
||||
Container.get(WorkflowStatisticsService).emit('nodeFetchedData', {
|
||||
workflowId: workflow.id,
|
||||
node: workflowStartNode,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const error = ensureError(e);
|
||||
// Send error response to webhook caller
|
||||
const webhookType = ['formTrigger', 'form'].includes(nodeType.description.name)
|
||||
? 'Form'
|
||||
: 'Webhook';
|
||||
const errorMessage = _privateGetWebhookErrorMessage(error, webhookType);
|
||||
|
||||
Container.get(ErrorReporter).error(error, {
|
||||
extra: {
|
||||
nodeName: workflowStartNode.name,
|
||||
nodeType: workflowStartNode.type,
|
||||
nodeVersion: workflowStartNode.typeVersion,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
});
|
||||
|
||||
responseCallback(new UnexpectedError(errorMessage), {});
|
||||
didSendResponse = true;
|
||||
|
||||
// Add error to execution data that it can be logged and send to Editor-UI
|
||||
runExecutionDataMerge = {
|
||||
resultData: {
|
||||
runData: {},
|
||||
lastNodeExecuted: workflowStartNode.name,
|
||||
error: {
|
||||
...error,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
webhookResultData = {
|
||||
noWebhookResponse: true,
|
||||
// Add empty data that it at least tries to "execute" the webhook
|
||||
// which then so gets the chance to throw the error.
|
||||
workflowData: [[{ json: {} }]],
|
||||
};
|
||||
}
|
||||
const invocationResult = await invokeWebhook({
|
||||
workflow,
|
||||
webhookData,
|
||||
workflowStartNode,
|
||||
additionalData,
|
||||
executionMode,
|
||||
runExecutionData,
|
||||
webhookType: ['formTrigger', 'form'].includes(nodeType.description.name) ? 'Form' : 'Webhook',
|
||||
responseCallback,
|
||||
});
|
||||
const { webhookResultData } = invocationResult;
|
||||
didSendResponse = invocationResult.didSendResponse;
|
||||
runExecutionDataMerge = invocationResult.runExecutionDataChanges;
|
||||
|
||||
if (cleanupMultipartFiles && webhookResultData.webhookResponse instanceof Readable) {
|
||||
deferCleanupUntilStreamEnds(webhookResultData.webhookResponse, res, cleanupMultipartFiles);
|
||||
|
||||
Reference in New Issue
Block a user