mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-26 14:25:35 +02:00
199 lines
5.4 KiB
TypeScript
199 lines
5.4 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* Workflow Structure Validation
|
|
*
|
|
* Single source of truth for validating workflow **structure** — the minimum
|
|
* shape that the editor and runtime assume is always present and correctly
|
|
* formed. Intentionally separate from activation/publish validation
|
|
* (WorkflowValidationService) which checks semantic correctness (trigger
|
|
* presence, known node types, credential issues, etc.).
|
|
*
|
|
* Lives in n8n-workflow so it can be shared by:
|
|
* - Backend: create/update/import reject malformed payloads (400)
|
|
* - Frontend: open path warns but still renders; import path blocks
|
|
*
|
|
* Validates:
|
|
* - Required node fields (name, type, position)
|
|
* - Position is a 2-number tuple
|
|
* - Connection entries have valid node/type/index
|
|
* - No duplicate node names
|
|
* - Connection source/target keys reference existing nodes
|
|
*
|
|
* Does NOT validate:
|
|
* - Whether a node type is installed
|
|
* - Node parameter correctness
|
|
* - Credential validity
|
|
* - Activation readiness
|
|
*
|
|
*/
|
|
|
|
const workflowNodeStructureSchema = z
|
|
.object({
|
|
name: z.string().min(1),
|
|
type: z.string().min(1),
|
|
position: z.tuple([z.number(), z.number()]),
|
|
parameters: z.record(z.string(), z.unknown()).optional(),
|
|
id: z.string().optional(),
|
|
typeVersion: z.number().optional(),
|
|
disabled: z.boolean().optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const connectionEntrySchema = z
|
|
.object({
|
|
node: z.string().min(1),
|
|
type: z.string().min(1),
|
|
index: z.number().int().min(0),
|
|
})
|
|
.passthrough();
|
|
|
|
// Buckets can be null when a multi-output node has unused output slots.
|
|
// Matches NodeInputConnections type.
|
|
const connectionBucketSchema = z.array(connectionEntrySchema).nullable().optional();
|
|
|
|
const workflowConnectionsStructureSchema = z.record(
|
|
z.string(),
|
|
z.record(z.string(), z.array(connectionBucketSchema)),
|
|
);
|
|
|
|
const workflowStructureSchema = z.object({
|
|
nodes: z.array(workflowNodeStructureSchema),
|
|
connections: workflowConnectionsStructureSchema,
|
|
});
|
|
|
|
type WorkflowStructureData = z.infer<typeof workflowStructureSchema>;
|
|
|
|
type WorkflowStructureGraphIssueCode =
|
|
| 'duplicate_node_name'
|
|
| 'unknown_connection_source'
|
|
| 'unknown_connection_target';
|
|
|
|
type WorkflowStructureGraphIssue = {
|
|
code: WorkflowStructureGraphIssueCode;
|
|
path: Array<string | number>;
|
|
message: string;
|
|
};
|
|
|
|
export type WorkflowStructureIssue = z.ZodIssue | WorkflowStructureGraphIssue;
|
|
|
|
type WorkflowStructureValidationSuccess = {
|
|
success: true;
|
|
data: WorkflowStructureData;
|
|
};
|
|
|
|
type WorkflowStructureValidationFailure = {
|
|
success: false;
|
|
issues: WorkflowStructureIssue[];
|
|
};
|
|
|
|
export type WorkflowStructureValidationResult =
|
|
| WorkflowStructureValidationSuccess
|
|
| WorkflowStructureValidationFailure;
|
|
|
|
export const formatWorkflowStructureIssuePath = (path: Array<string | number>): string => {
|
|
if (path.length === 0) return 'workflow';
|
|
|
|
return path.reduce<string>((acc, segment) => {
|
|
if (typeof segment === 'number') return `${acc}[${segment}]`;
|
|
return acc ? `${acc}.${segment}` : segment;
|
|
}, '');
|
|
};
|
|
|
|
const formatIssuesDescription = (issues: WorkflowStructureIssue[]): string =>
|
|
issues
|
|
.map(({ path, message }) => `${formatWorkflowStructureIssuePath(path)}: ${message}`)
|
|
.join('\n');
|
|
|
|
export class WorkflowStructureValidationError extends Error {
|
|
override name = 'WorkflowStructureValidationError';
|
|
|
|
readonly description: string;
|
|
|
|
constructor(readonly issues: WorkflowStructureIssue[]) {
|
|
super('Invalid workflow structure');
|
|
this.description = formatIssuesDescription(issues);
|
|
}
|
|
}
|
|
|
|
export function safeParseWorkflowStructure(input: unknown): WorkflowStructureValidationResult {
|
|
const parsed = workflowStructureSchema.safeParse(input);
|
|
|
|
if (!parsed.success) {
|
|
return {
|
|
success: false,
|
|
issues: parsed.error.issues,
|
|
};
|
|
}
|
|
|
|
const { nodes, connections } = parsed.data;
|
|
const issues: WorkflowStructureIssue[] = [];
|
|
const nodeNames = new Set<string>();
|
|
|
|
for (const [index, node] of nodes.entries()) {
|
|
if (nodeNames.has(node.name)) {
|
|
issues.push({
|
|
path: ['nodes', index, 'name'],
|
|
message: `Duplicate node name "${node.name}"`,
|
|
code: 'duplicate_node_name',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
nodeNames.add(node.name);
|
|
}
|
|
|
|
for (const sourceNodeName of Object.keys(connections)) {
|
|
if (!nodeNames.has(sourceNodeName)) {
|
|
issues.push({
|
|
path: ['connections', sourceNodeName],
|
|
message: `Connection source "${sourceNodeName}" does not reference an existing node`,
|
|
code: 'unknown_connection_source',
|
|
});
|
|
}
|
|
|
|
const connectionTypes = connections[sourceNodeName];
|
|
for (const connectionType of Object.keys(connectionTypes)) {
|
|
const buckets = connectionTypes[connectionType];
|
|
|
|
for (const [sourceIndex, bucket] of buckets.entries()) {
|
|
for (const [targetIndex, connection] of (bucket ?? []).entries()) {
|
|
if (!nodeNames.has(connection.node)) {
|
|
issues.push({
|
|
path: [
|
|
'connections',
|
|
sourceNodeName,
|
|
connectionType,
|
|
sourceIndex,
|
|
targetIndex,
|
|
'node',
|
|
],
|
|
message: `Connection target "${connection.node}" does not reference an existing node`,
|
|
code: 'unknown_connection_target',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (issues.length > 0) {
|
|
return { success: false, issues };
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: parsed.data,
|
|
};
|
|
}
|
|
|
|
export function parseWorkflowStructure(input: unknown): WorkflowStructureData {
|
|
const result = safeParseWorkflowStructure(input);
|
|
|
|
if (!result.success) {
|
|
throw new WorkflowStructureValidationError(result.issues);
|
|
}
|
|
|
|
return result.data;
|
|
}
|