studio / app /services /validation.py
Ava2lon's picture
Upload 12 files
1319f7a verified
Raw
History Blame
7.65 kB
import re
from collections import defaultdict
from typing import Protocol
from app.models.workflow import ValidationIssue, ValidationResult, WorkflowDocument
from app.services.catalog import SUPPORTED_NODE_TYPES
EXPRESSION_RE = re.compile(r"^=.*\{\{.+\}\}.*$", re.DOTALL)
class ValidationRule(Protocol):
code: str
def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]: ...
class TriggerRule:
code = "missing_trigger"
def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
if any(node.data.category == "trigger" for node in workflow.nodes):
return []
return [
ValidationIssue(
code=self.code,
severity="error",
message="Workflow does not have a trigger node.",
suggestion="Add a Webhook, Schedule Trigger, or service trigger.",
)
]
class CredentialRule:
code = "missing_credentials"
def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
issues = []
for node in workflow.nodes:
for credential in (node.data.credentials or {}).values():
if not credential.id:
issues.append(
ValidationIssue(
code=self.code,
severity="error",
nodeId=node.id,
message=f"{node.data.label} has an unconfigured credential.",
suggestion=f"Select a {credential.type} credential before activation.",
)
)
return issues
class ConnectionRule:
code = "broken_connection"
def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
ids = {node.id for node in workflow.nodes}
issues = []
for edge in workflow.edges:
if edge.source not in ids or edge.target not in ids:
issues.append(
ValidationIssue(
code=self.code,
severity="error",
message=f"Connection {edge.id} references a missing node.",
suggestion="Delete the connection or reconnect it to an existing node.",
)
)
connected = {edge.source for edge in workflow.edges} | {
edge.target for edge in workflow.edges
}
for node in workflow.nodes:
if len(workflow.nodes) > 1 and node.id not in connected:
issues.append(
ValidationIssue(
code="disconnected_node",
severity="warning",
nodeId=node.id,
message=f"{node.data.label} is not connected.",
suggestion="Connect or remove this node.",
)
)
return issues
class LoopRule:
code = "infinite_loop"
def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
graph: dict[str, list[str]] = defaultdict(list)
for edge in workflow.edges:
graph[edge.source].append(edge.target)
visiting: set[str] = set()
visited: set[str] = set()
def has_cycle(node_id: str) -> bool:
if node_id in visiting:
return True
if node_id in visited:
return False
visiting.add(node_id)
if any(has_cycle(target) for target in graph[node_id]):
return True
visiting.remove(node_id)
visited.add(node_id)
return False
if any(has_cycle(node.id) for node in workflow.nodes if node.id not in visited):
return [
ValidationIssue(
code=self.code,
severity="error",
message="Workflow contains a cycle that may execute indefinitely.",
suggestion="Break the cycle or add an explicit loop termination condition.",
)
]
return []
class ParameterRule:
code = "empty_parameters"
def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
issues = []
for node in workflow.nodes:
if node.data.category != "trigger" and not node.data.parameters:
issues.append(
ValidationIssue(
code=self.code,
severity="warning",
nodeId=node.id,
message=f"{node.data.label} has no parameters.",
suggestion="Configure the required operation and resource fields.",
)
)
return issues
class ExpressionRule:
code = "invalid_expression"
def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
issues = []
def inspect(value, node_id: str, label: str) -> None:
if isinstance(value, dict):
for nested in value.values():
inspect(nested, node_id, label)
elif isinstance(value, list):
for nested in value:
inspect(nested, node_id, label)
elif isinstance(value, str) and ("{{" in value or "}}" in value):
if value.count("{{") != value.count("}}") or not EXPRESSION_RE.match(value):
issues.append(
ValidationIssue(
code=self.code,
severity="error",
nodeId=node_id,
message=f"{label} contains an invalid n8n expression.",
suggestion="Expressions should start with = and use balanced {{ }} braces.",
)
)
for node in workflow.nodes:
inspect(node.data.parameters, node.id, node.data.label)
return issues
class SupportedNodeRule:
code = "unsupported_node"
def validate(self, workflow: WorkflowDocument) -> list[ValidationIssue]:
return [
ValidationIssue(
code=self.code,
severity="warning",
nodeId=node.id,
message=f"{node.data.label} is not in the bundled node catalog.",
suggestion="Confirm that the node package is installed on the target n8n instance.",
)
for node in workflow.nodes
if node.data.type not in SUPPORTED_NODE_TYPES
and not node.data.type.startswith("@n8n/n8n-nodes-langchain.")
]
class Validator:
def __init__(self) -> None:
self.rules: list[ValidationRule] = []
def register(self, rule: ValidationRule) -> None:
self.rules.append(rule)
def validate(self, workflow: WorkflowDocument) -> ValidationResult:
issues = [issue for rule in self.rules for issue in rule.validate(workflow)]
deductions = {"error": 18, "warning": 6, "info": 1}
score = max(0, 100 - sum(deductions[issue.severity] for issue in issues))
return ValidationResult(
valid=not any(issue.severity == "error" for issue in issues),
score=score,
issues=issues,
)
validator = Validator()
for validation_rule in (
TriggerRule(),
CredentialRule(),
ConnectionRule(),
LoopRule(),
ParameterRule(),
ExpressionRule(),
SupportedNodeRule(),
):
validator.register(validation_rule)