Compare commits
2 Commits
dev
...
dev_roeiba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb81f93a24 | ||
|
|
344054572a |
133
.github/workflows/validate-workflows.yml
vendored
Normal file
133
.github/workflows/validate-workflows.yml
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
name: Validate n8n Workflows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
validate-workflows:
|
||||
name: Validate n8n Workflows
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./lib
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
|
||||
- name: Run workflow validation
|
||||
id: validate
|
||||
working-directory: ./lib
|
||||
run: |
|
||||
# Run the validator on all JSON files in the repository
|
||||
# This will fail if any workflow is invalid
|
||||
echo "Validating all n8n workflows..."
|
||||
if ! n8n-validate ..; then
|
||||
echo "::error::One or more workflow validations failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All workflows are valid!"
|
||||
|
||||
- name: Create visualization artifacts
|
||||
if: always() # Run this step even if validation fails
|
||||
working-directory: ./lib
|
||||
run: |
|
||||
echo "Creating visualizations for all workflows..."
|
||||
mkdir -p ../workflow-visualizations
|
||||
|
||||
# Find all JSON files that might be n8n workflows
|
||||
find .. -type f -name "*.json" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/workflow-visualizations/*" | while read -r file; do
|
||||
# Try to validate the file first
|
||||
if n8n-validate "$file" 2>/dev/null; then
|
||||
# If validation passes, create a visualization
|
||||
echo "Creating visualization for $file"
|
||||
filename=$(basename "$file" .json)
|
||||
output_file="../workflow-visualizations/${filename}.png"
|
||||
if ! n8n-visualize "$file" -o "$output_file" --no-show 2>/dev/null; then
|
||||
echo "::warning::Failed to create visualization for $file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Count the number of visualizations created
|
||||
VIS_COUNT=$(find ../workflow-visualizations -type f -name "*.png" | wc -l)
|
||||
echo "Created $VIS_COUNT workflow visualizations"
|
||||
|
||||
# Set an output with the visualization count
|
||||
echo "visualization_count=$VIS_COUNT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload workflow visualizations
|
||||
if: always() && steps.validate.outcome == 'success'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: workflow-visualizations
|
||||
path: workflow-visualizations/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment on PR with validation results
|
||||
if: github.event_name == 'pull_request' && steps.validate.outcome == 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Get the list of workflow files that were validated
|
||||
const workflowFiles = execSync('find .. -type f -name "*.json" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/workflow-visualizations/*"')
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
|
||||
// Count visualizations
|
||||
let visCount = 0;
|
||||
try {
|
||||
visCount = fs.readdirSync('../workflow-visualizations').length;
|
||||
} catch (e) {
|
||||
// Directory might not exist if no visualizations were created
|
||||
}
|
||||
|
||||
// Create a comment
|
||||
const comment = `✅ All ${workflowFiles.length} n8n workflow files are valid!\n` +
|
||||
`📊 ${visCount} workflow visualizations were generated and attached as artifacts.`;
|
||||
|
||||
// Add a comment to the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('n8n workflow')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: comment,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment,
|
||||
});
|
||||
}
|
||||
170
lib/examples/visualize_example.py
Normal file
170
lib/examples/visualize_example.py
Normal 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()
|
||||
8
lib/n8n_utils/__init__.py
Normal file
8
lib/n8n_utils/__init__.py
Normal 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"
|
||||
15
lib/n8n_utils/ci/__init__.py
Normal file
15
lib/n8n_utils/ci/__init__.py
Normal 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',
|
||||
]
|
||||
158
lib/n8n_utils/ci/validator.py
Normal file
158
lib/n8n_utils/ci/validator.py
Normal 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()
|
||||
79
lib/n8n_utils/tests/test_workflow_validation.py
Normal file
79
lib/n8n_utils/tests/test_workflow_validation.py
Normal 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
7
lib/requirements.txt
Normal 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
39
lib/setup.py
Normal 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",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user