""" Mock API Server for N8N Testing This module provides a REST API server that serves data from text files. N8N workflows can call this API to get consistent test data stored in files. """ import json import logging import os import random import threading import time from datetime import datetime from pathlib import Path from typing import Dict, List, Any, Optional, Union from flask import Flask, jsonify, request, Response from werkzeug.serving import make_server logger = logging.getLogger(__name__) class MockAPIServer: """REST API server that serves data from text files.""" def __init__(self, data_dir: str = "api_data", host: str = "0.0.0.0", port: int = 5000): """ Initialize the mock API server. Args: data_dir: Directory containing data files host: Host to bind the server to port: Port to run the server on """ self.data_dir = Path(data_dir) self.data_dir.mkdir(exist_ok=True) self.host = host self.port = port self.app = Flask(__name__) self.server = None self.server_thread = None self.is_running = False # Setup routes self._setup_routes() # Create example data file if it doesn't exist self._create_example_data() def _setup_routes(self): """Setup API routes.""" @self.app.route('/health', methods=['GET']) def health_check(): """Health check endpoint.""" return jsonify({ 'status': 'healthy', 'timestamp': datetime.now().isoformat(), 'data_dir': str(self.data_dir), 'available_endpoints': self._get_available_endpoints() }) @self.app.route('/data/', methods=['GET']) def get_data(filename): """Get data from a specific file.""" return self._serve_data_file(filename) @self.app.route('/data', methods=['GET']) def list_data_files(): """List available data files.""" files = [] for file_path in self.data_dir.glob('*.json'): files.append({ 'name': file_path.stem, 'filename': file_path.name, 'size': file_path.stat().st_size, 'modified': datetime.fromtimestamp(file_path.stat().st_mtime).isoformat(), 'url': f"http://{self.host}:{self.port}/data/{file_path.name}" }) return jsonify({ 'files': files, 'count': len(files) }) @self.app.route('/matrix', methods=['GET']) def get_matrix_data(): """Get Matrix chat data (example endpoint).""" return self._serve_data_file('matrix_messages.json') @self.app.route('/random/', methods=['GET']) def get_random_data(filename): """Get random item from data file.""" data = self._load_data_file(filename) if not data: return jsonify({'error': 'File not found or empty'}), 404 if isinstance(data, list) and data: return jsonify(random.choice(data)) else: return jsonify(data) @self.app.route('/paginated/', methods=['GET']) def get_paginated_data(filename): """Get paginated data from file.""" page = int(request.args.get('page', 1)) per_page = int(request.args.get('per_page', 10)) data = self._load_data_file(filename) if not data: return jsonify({'error': 'File not found or empty'}), 404 if isinstance(data, list): start_idx = (page - 1) * per_page end_idx = start_idx + per_page paginated_data = data[start_idx:end_idx] return jsonify({ 'data': paginated_data, 'page': page, 'per_page': per_page, 'total': len(data), 'total_pages': (len(data) + per_page - 1) // per_page }) else: return jsonify({ 'data': data, 'page': 1, 'per_page': 1, 'total': 1, 'total_pages': 1 }) @self.app.route('/upload', methods=['POST']) def upload_data(): """Upload new data to a file.""" try: filename = request.args.get('filename') if not filename: return jsonify({'error': 'filename parameter required'}), 400 data = request.get_json() if not data: return jsonify({'error': 'JSON data required'}), 400 filepath = self.data_dir / f"{filename}.json" with open(filepath, 'w') as f: json.dump(data, f, indent=2) return jsonify({ 'message': f'Data uploaded successfully to {filename}.json', 'filename': f"{filename}.json", 'size': filepath.stat().st_size }) except Exception as e: return jsonify({'error': str(e)}), 500 @self.app.errorhandler(404) def not_found(error): return jsonify({'error': 'Endpoint not found'}), 404 @self.app.errorhandler(500) def internal_error(error): return jsonify({'error': 'Internal server error'}), 500 def _serve_data_file(self, filename: str) -> Response: """Serve data from a specific file.""" # Add .json extension if not present if not filename.endswith('.json'): filename += '.json' data = self._load_data_file(filename) if data is None: return jsonify({'error': f'File {filename} not found'}), 404 # Add some variation to timestamps if present varied_data = self._add_timestamp_variation(data) return jsonify(varied_data) def _load_data_file(self, filename: str) -> Optional[Union[Dict, List]]: """Load data from a JSON file.""" filepath = self.data_dir / filename if not filepath.exists(): logger.warning(f"Data file not found: {filepath}") return None try: with open(filepath, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: logger.error(f"Error loading data file {filepath}: {e}") return None def _add_timestamp_variation(self, data: Union[Dict, List]) -> Union[Dict, List]: """Add slight variations to timestamps to simulate real data.""" if isinstance(data, dict): return self._vary_dict_timestamps(data) elif isinstance(data, list): return [self._vary_dict_timestamps(item) if isinstance(item, dict) else item for item in data] else: return data def _vary_dict_timestamps(self, data: Dict) -> Dict: """Add variation to timestamps in a dictionary.""" varied_data = data.copy() # Common timestamp fields to vary timestamp_fields = ['timestamp', 'origin_server_ts', 'created_at', 'updated_at', 'time', 'date'] for key, value in varied_data.items(): if key in timestamp_fields and isinstance(value, (int, float)): # Add random variation of ±5 minutes for timestamp fields variation = random.randint(-300000, 300000) # ±5 minutes in milliseconds varied_data[key] = value + variation elif key == 'age' and isinstance(value, (int, float)): # Add small random variation to age fields variation = random.randint(-1000, 1000) varied_data[key] = max(0, value + variation) elif isinstance(value, dict): varied_data[key] = self._vary_dict_timestamps(value) elif isinstance(value, list): varied_data[key] = [ self._vary_dict_timestamps(item) if isinstance(item, dict) else item for item in value ] return varied_data def _get_available_endpoints(self) -> List[str]: """Get list of available API endpoints.""" endpoints = [ '/health', '/data', '/data/', '/random/', '/paginated/', '/upload', '/matrix' ] # Add endpoints for each data file for file_path in self.data_dir.glob('*.json'): endpoints.append(f'/data/{file_path.name}') return endpoints def _create_example_data(self): """Create example data files if they don't exist.""" # Create matrix messages example matrix_file = self.data_dir / 'matrix_messages.json' if not matrix_file.exists(): matrix_example = [ { "chunk": [ { "type": "m.room.message", "room_id": "!xZkScMybPseErYMJDz:matrix.klas.chat", "sender": "@signal_37c02a2c-31a2-4937-88f2-3f6be48afcdc:matrix.klas.chat", "content": { "body": "Viděli jsme dopravní nehodu", "m.mentions": {}, "msgtype": "m.text" }, "origin_server_ts": 1749927752871, "unsigned": { "membership": "join", "age": 668 }, "event_id": "$k2t8Uj8K9tdtXfuyvnxezL-Gijqb0Bw4rucgZ0rEOgA", "user_id": "@signal_37c02a2c-31a2-4937-88f2-3f6be48afcdc:matrix.klas.chat", "age": 668 }, { "type": "m.room.message", "room_id": "!xZkScMybPseErYMJDz:matrix.klas.chat", "sender": "@signal_961d5a74-062f-4f22-88bd-e192a5e7d567:matrix.klas.chat", "content": { "body": "BTC fee je: 1 sat/vByte", "m.mentions": {}, "msgtype": "m.text" }, "origin_server_ts": 1749905152683, "unsigned": { "membership": "join", "age": 22623802 }, "event_id": "$WYbz0dB8f16PxL9_j0seJbO1tFaSGiiWeDaj8yGEfC8", "user_id": "@signal_961d5a74-062f-4f22-88bd-e192a5e7d567:matrix.klas.chat", "age": 22623802 } ], "start": "t404-16991_0_0_0_0_0_0_0_0_0", "end": "t395-16926_0_0_0_0_0_0_0_0_0" } ] with open(matrix_file, 'w', encoding='utf-8') as f: json.dump(matrix_example, f, indent=2, ensure_ascii=False) logger.info(f"Created example matrix messages file: {matrix_file}") # Create simple test data example test_file = self.data_dir / 'test_data.json' if not test_file.exists(): test_data = { "message": "Hello from mock API", "timestamp": int(time.time() * 1000), "items": [ {"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}, {"id": 3, "name": "Item 3"} ] } with open(test_file, 'w') as f: json.dump(test_data, f, indent=2) logger.info(f"Created example test data file: {test_file}") def start(self, debug: bool = False, threaded: bool = True) -> bool: """ Start the API server. Args: debug: Enable debug mode threaded: Run in a separate thread Returns: True if server started successfully """ try: if self.is_running: logger.warning("Server is already running") return False self.server = make_server(self.host, self.port, self.app, threaded=True) if threaded: self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.server_thread.start() else: self.server.serve_forever() self.is_running = True logger.info(f"Mock API server started on http://{self.host}:{self.port}") logger.info(f"Health check: http://{self.host}:{self.port}/health") logger.info(f"Data files: http://{self.host}:{self.port}/data") return True except Exception as e: logger.error(f"Failed to start server: {e}") return False def stop(self): """Stop the API server.""" if self.server and self.is_running: self.server.shutdown() if self.server_thread: self.server_thread.join(timeout=5) self.is_running = False logger.info("Mock API server stopped") else: logger.warning("Server is not running") def add_data_file(self, filename: str, data: Union[Dict, List]) -> str: """ Add a new data file. Args: filename: Name of the file (without .json extension) data: Data to store Returns: Path to the created file """ if not filename.endswith('.json'): filename += '.json' filepath = self.data_dir / filename with open(filepath, 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False) logger.info(f"Added data file: {filepath}") return str(filepath) def get_server_info(self) -> Dict[str, Any]: """Get information about the server.""" return { 'host': self.host, 'port': self.port, 'is_running': self.is_running, 'data_dir': str(self.data_dir), 'base_url': f"http://{self.host}:{self.port}", 'health_url': f"http://{self.host}:{self.port}/health", 'data_files_count': len(list(self.data_dir.glob('*.json'))), 'available_endpoints': self._get_available_endpoints() } def create_mock_api_server(data_dir: str = "api_data", host: str = "0.0.0.0", port: int = 5000): """Create a mock API server instance.""" return MockAPIServer(data_dir, host, port) # CLI functionality for standalone usage if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Mock API Server for N8N Testing") parser.add_argument("--host", default="0.0.0.0", help="Host to bind to") parser.add_argument("--port", type=int, default=5000, help="Port to bind to") parser.add_argument("--data-dir", default="api_data", help="Directory for data files") parser.add_argument("--debug", action="store_true", help="Enable debug mode") args = parser.parse_args() # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) # Create and start server server = MockAPIServer(args.data_dir, args.host, args.port) try: print(f"Starting Mock API Server on http://{args.host}:{args.port}") print(f"Data directory: {args.data_dir}") print("Press Ctrl+C to stop") server.start(debug=args.debug, threaded=False) except KeyboardInterrupt: print("\nShutting down server...") server.stop() print("Server stopped")