Add CI code and logic to validate flows

This commit is contained in:
Roei Bar Aviv
2025-05-22 15:44:54 +02:00
parent 3b348c3de0
commit 344054572a
8 changed files with 609 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
"""
Example script demonstrating how to use the n8n_utils visualization.
"""
import json
import os
import sys
from pathlib import Path
# Add the lib directory to the path so we can import n8n_utils
sys.path.insert(0, str(Path(__file__).parent.parent))
from n8n_utils.visualization import visualize_workflow
def create_sample_workflow():
"""Create a sample n8n workflow for demonstration."""
return {
"name": "Sample Workflow",
"nodes": [
{
"id": "1",
"name": "Start",
"type": "n8n-nodes-base.start",
"typeVersion": 1,
"position": [250, 300],
"parameters": {}
},
{
"id": "2",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [450, 200],
"parameters": {
"url": "https://api.example.com/data",
"method": "GET"
}
},
{
"id": "3",
"name": "Process Data",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [650, 300],
"parameters": {
"functionCode": "// Process the data here\nreturn items;"
}
},
{
"id": "4",
"name": "Condition",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [850, 300],
"parameters": {
"conditions": {
"string": [
{
"value1": "={{ $json.someField }}",
"operation": "exists"
}
]
}
}
},
{
"id": "5",
"name": "Send Email",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 1,
"position": [1050, 200],
"parameters": {
"to": "user@example.com",
"subject": "Processing Complete",
"text": "The data has been processed successfully."
}
},
{
"id": "6",
"name": "Log Error",
"type": "n8n-nodes-base.function",
"typeVersion": 1,
"position": [1050, 400],
"parameters": {
"functionCode": "console.log('Error processing data:', items);\nreturn items;"
}
}
],
"connections": {
"1": {
"main": [
[
{
"node": "2",
"type": "main",
"index": 0
}
]
]
},
"2": {
"main": [
[
{
"node": "3",
"type": "main",
"index": 0
}
]
]
},
"3": {
"main": [
[
{
"node": "4",
"type": "main",
"index": 0
}
]
]
},
"4": {
"main": [
[
{
"node": "5",
"type": "main",
"index": 0
}
]
]
},
"4-1": {
"main": [
[
{
"node": "6",
"type": "main",
"index": 0
}
]
]
}
}
}
def main():
"""Run the example."""
# Create output directory if it doesn't exist
output_dir = Path(__file__).parent / "output"
output_dir.mkdir(exist_ok=True)
# Create a sample workflow
workflow = create_sample_workflow()
# Save the workflow as JSON
workflow_file = output_dir / "sample_workflow.json"
with open(workflow_file, 'w', encoding='utf-8') as f:
json.dump(workflow, f, indent=2)
print(f"Created sample workflow at: {workflow_file}")
# Visualize the workflow
output_image = output_dir / "workflow_visualization.png"
from n8n_utils.visualization.visualizer import visualize_workflow
visualize_workflow(workflow, output_file=str(output_image), show=True)
print(f"Workflow visualization saved to: {output_image}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,8 @@
"""
n8n Utils
A collection of utilities for working with n8n workflows,
including validation and visualization tools.
"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,15 @@
"""
n8n CI Utilities
This package provides tools for CI/CD integration with n8n workflows,
including validation and testing utilities.
"""
from .validator import validate_workflow, validate_workflow_file, validate_all_workflows, ValidationError
__all__ = [
'validate_workflow',
'validate_workflow_file',
'validate_all_workflows',
'ValidationError',
]

View File

@@ -0,0 +1,158 @@
"""
n8n Workflow Validator
This module provides functionality to validate n8n workflow JSON files
against a schema to ensure they are properly formatted.
"""
import json
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional, Union
import jsonschema
from jsonschema import validate
# Define the n8n workflow JSON schema
N8N_WORKFLOW_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["nodes", "connections"],
"properties": {
"name": {"type": "string"},
"nodes": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "name", "type", "typeVersion", "position", "parameters"],
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"type": {"type": "string"},
"typeVersion": {"type": ["number", "string"]},
"position": {
"type": "array",
"items": {"type": "number"},
"minItems": 2,
"maxItems": 2
},
"parameters": {"type": "object"},
},
},
},
"connections": {"type": "object"},
},
}
class ValidationError(Exception):
"""Custom exception for validation errors."""
pass
def validate_workflow(workflow_data: Dict) -> List[str]:
"""
Validate an n8n workflow against the schema.
Args:
workflow_data: The parsed JSON data of the workflow
Returns:
List of error messages, empty if valid
"""
try:
validate(instance=workflow_data, schema=N8N_WORKFLOW_SCHEMA)
return []
except jsonschema.exceptions.ValidationError as e:
return [f"Validation error: {e.message} at {'.'.join(map(str, e.path))}"]
def validate_workflow_file(file_path: Union[str, Path]) -> List[str]:
"""
Validate an n8n workflow file.
Args:
file_path: Path to the JSON file to validate
Returns:
List of error messages, empty if valid
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
try:
workflow_data = json.load(f)
except json.JSONDecodeError as e:
return [f"Invalid JSON in {file_path}: {str(e)}"]
return validate_workflow(workflow_data)
except Exception as e:
return [f"Error reading {file_path}: {str(e)}"]
def find_workflow_files(directory: Union[str, Path]) -> List[Path]:
"""
Recursively find all JSON files in a directory that might be n8n workflows.
Args:
directory: Directory to search in
Returns:
List of Path objects to potential workflow files
"""
directory = Path(directory)
return list(directory.glob("**/*.json"))
def validate_all_workflows(directory: Union[str, Path]) -> Dict[str, List[str]]:
"""
Validate all JSON files in a directory and its subdirectories.
Args:
directory: Directory to search for workflow files
Returns:
Dictionary mapping file paths to lists of error messages
"""
workflow_files = find_workflow_files(directory)
results = {}
for file_path in workflow_files:
errors = validate_workflow_file(file_path)
if errors:
results[str(file_path)] = errors
return results
def main():
"""Command-line interface for the validator."""
import argparse
parser = argparse.ArgumentParser(description="Validate n8n workflow files.")
parser.add_argument(
"directory",
nargs="?",
default=".",
help="Directory containing n8n workflow files (default: current directory)",
)
args = parser.parse_args()
directory = Path(args.directory).resolve()
if not directory.exists():
print(f"Error: Directory '{directory}' does not exist")
sys.exit(1)
print(f"Validating n8n workflows in: {directory}")
results = validate_all_workflows(directory)
if not results:
print("✅ All workflow files are valid!")
sys.exit(0)
# Print errors
error_count = 0
for file_path, errors in results.items():
print(f"\n{file_path}:")
for error in errors:
print(f" - {error}")
error_count += 1
print(f"\nFound {error_count} error(s) in {len(results)} file(s)")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,79 @@
"""Tests for n8n workflow validation."""
import json
import os
import tempfile
import unittest
from pathlib import Path
from n8n_utils.ci.validator import validate_workflow, ValidationError
class TestWorkflowValidation(unittest.TestCase):
"""Test cases for workflow validation."""
def setUp(self):
"""Set up test fixtures."""
self.valid_workflow = {
"name": "Test Workflow",
"nodes": [
{
"id": "1",
"name": "Start",
"type": "n8n-nodes-base.start",
"typeVersion": 1,
"position": [250, 300],
"parameters": {}
},
{
"id": "2",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [450, 300],
"parameters": {
"url": "https://example.com",
"method": "GET"
}
}
],
"connections": {
"1": {
"main": [
[
{
"node": "2",
"type": "main",
"index": 0
}
]
]
}
}
}
def test_valid_workflow(self):
"""Test validation of a valid workflow."""
errors = validate_workflow(self.valid_workflow)
self.assertEqual(len(errors), 0)
def test_missing_required_field(self):
"""Test validation of a workflow with a missing required field."""
# Remove a required field
invalid_workflow = self.valid_workflow.copy()
del invalid_workflow["nodes"][0]["id"]
errors = validate_workflow(invalid_workflow)
self.assertGreater(len(errors), 0)
self.assertIn("id", errors[0])
def test_invalid_node_structure(self):
"""Test validation of a workflow with invalid node structure."""
invalid_workflow = self.valid_workflow.copy()
# Make position an invalid type
invalid_workflow["nodes"][0]["position"] = "not an array"
errors = validate_workflow(invalid_workflow)
self.assertGreater(len(errors), 0)
self.assertIn("position", errors[0])
if __name__ == "__main__":
unittest.main()

7
lib/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
jsonschema>=4.0.0
networkx>=2.6.3
matplotlib>=3.4.3
pytest>=7.0.0
pytest-cov>=3.0.0
click>=8.0.0
python-dotenv>=0.19.0

39
lib/setup.py Normal file
View File

@@ -0,0 +1,39 @@
from setuptools import setup, find_packages
import os
# Get the long description from the README file
here = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(here, '..', 'README.md'), encoding='utf-8') as f:
long_description = f.read()
setup(
name="n8n_utils",
version="0.1.0",
packages=find_packages(where='.'),
package_dir={'': '.'},
install_requires=[
"jsonschema>=4.0.0",
"networkx>=2.6.3",
"matplotlib>=3.4.3",
"click>=8.0.0",
"python-dotenv>=0.19.0",
],
entry_points={
'console_scripts': [
'n8n-validate=n8n_utils.ci.validator:main',
'n8n-visualize=n8n_utils.visualization.visualizer:main',
],
},
python_requires='>=3.8',
author="Your Name",
author_email="your.email@example.com",
description="Utilities for n8n workflow validation and visualization",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/yourusername/n8n-free-templates",
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
)