#!/usr/bin/env python3 """ Generate type-safe models from OpenAPI and AsyncAPI specifications using Modelina. This skill uses AsyncAPI Modelina to generate models in various languages from API specifications. """ import sys import json import argparse import subprocess import shutil from pathlib import Path from typing import Dict, Any, List # Add betty module to path from betty.logging_utils import setup_logger from betty.errors import format_error_response, BettyError from betty.validation import validate_path logger = setup_logger(__name__) # Supported languages SUPPORTED_LANGUAGES = [ "typescript", "python", "java", "go", "csharp", "rust", "kotlin", "dart" ] # Language-specific configurations LANGUAGE_CONFIG = { "typescript": { "extension": ".ts", "package_json_required": False, "modelina_generator": "typescript" }, "python": { "extension": ".py", "package_json_required": False, "modelina_generator": "python" }, "java": { "extension": ".java", "package_json_required": False, "modelina_generator": "java" }, "go": { "extension": ".go", "package_json_required": False, "modelina_generator": "go" }, "csharp": { "extension": ".cs", "package_json_required": False, "modelina_generator": "csharp" } } def check_node_installed() -> bool: """ Check if Node.js is installed. Returns: True if Node.js is available, False otherwise """ try: result = subprocess.run( ["node", "--version"], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: version = result.stdout.strip() logger.info(f"Node.js found: {version}") return True return False except (FileNotFoundError, subprocess.TimeoutExpired): return False def check_npx_installed() -> bool: """ Check if npx is installed. Returns: True if npx is available, False otherwise """ try: result = subprocess.run( ["npx", "--version"], capture_output=True, text=True, timeout=5 ) return result.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False def generate_modelina_script( spec_path: str, language: str, output_dir: str, package_name: str = None ) -> str: """ Generate a Node.js script that uses Modelina to generate models. Args: spec_path: Path to spec file language: Target language output_dir: Output directory package_name: Package name (optional) Returns: JavaScript code as string """ # Modelina generator based on language generator_map = { "typescript": "TypeScriptGenerator", "python": "PythonGenerator", "java": "JavaGenerator", "go": "GoGenerator", "csharp": "CSharpGenerator" } generator_class = generator_map.get(language, "TypeScriptGenerator") script = f""" const {{ {generator_class} }} = require('@asyncapi/modelina'); const fs = require('fs'); const path = require('path'); async function generate() {{ try {{ // Read the spec file const spec = fs.readFileSync('{spec_path}', 'utf8'); const specData = JSON.parse(spec); // Create generator const generator = new {generator_class}(); // Generate models const models = await generator.generate(specData); // Ensure output directory exists const outputDir = '{output_dir}'; if (!fs.existsSync(outputDir)) {{ fs.mkdirSync(outputDir, {{ recursive: true }}); }} // Write models to files const filesGenerated = []; for (const model of models) {{ const filePath = path.join(outputDir, model.name + model.extension); fs.writeFileSync(filePath, model.result); filesGenerated.push(filePath); }} // Output result console.log(JSON.stringify({{ success: true, files_generated: filesGenerated, model_count: models.length }})); }} catch (error) {{ console.error(JSON.stringify({{ success: false, error: error.message, stack: error.stack }})); process.exit(1); }} }} generate(); """ return script def generate_models_datamodel_code_generator( spec_path: str, language: str, output_dir: str, package_name: str = None ) -> Dict[str, Any]: """ Generate models using datamodel-code-generator (Python fallback). This is used when Modelina/Node.js is not available. Works for OpenAPI specs only, generating Python/TypeScript models. Args: spec_path: Path to specification file language: Target language output_dir: Output directory package_name: Package name Returns: Result dictionary """ try: # Check if datamodel-code-generator is installed result = subprocess.run( ["datamodel-codegen", "--version"], capture_output=True, text=True, timeout=5 ) if result.returncode != 0: raise BettyError( "datamodel-code-generator not installed. " "Install with: pip install datamodel-code-generator" ) except FileNotFoundError: raise BettyError( "datamodel-code-generator not found. " "Install with: pip install datamodel-code-generator" ) # Create output directory output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) # Determine output file based on language if language == "python": output_file = output_path / "models.py" cmd = [ "datamodel-codegen", "--input", spec_path, "--output", str(output_file), "--input-file-type", "openapi", "--output-model-type", "pydantic_v2.BaseModel", "--snake-case-field", "--use-standard-collections" ] elif language == "typescript": output_file = output_path / "models.ts" cmd = [ "datamodel-codegen", "--input", spec_path, "--output", str(output_file), "--input-file-type", "openapi", "--output-model-type", "typescript" ] else: raise BettyError( f"datamodel-code-generator fallback only supports Python and TypeScript, not {language}" ) # Run code generator logger.info(f"Running datamodel-code-generator: {' '.join(cmd)}") result = subprocess.run( cmd, capture_output=True, text=True, timeout=60 ) if result.returncode != 0: raise BettyError(f"Code generation failed: {result.stderr}") # Count generated files files_generated = [str(output_file)] return { "models_path": str(output_path), "files_generated": files_generated, "model_count": 1, "generator_used": "datamodel-code-generator" } def generate_models_simple( spec_path: str, language: str, output_dir: str, package_name: str = None ) -> Dict[str, Any]: """ Simple model generation without external tools. Generates basic model files from OpenAPI schemas as a last resort. Args: spec_path: Path to specification file language: Target language output_dir: Output directory package_name: Package name Returns: Result dictionary """ import yaml # Load spec with open(spec_path, 'r') as f: spec = yaml.safe_load(f) # Get schemas schemas = spec.get("components", {}).get("schemas", {}) if not schemas: raise BettyError("No schemas found in specification") # Create output directory output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) files_generated = [] # Generate basic models for each schema for schema_name, schema_def in schemas.items(): if language == "typescript": content = generate_typescript_interface(schema_name, schema_def) file_path = output_path / f"{schema_name}.ts" elif language == "python": content = generate_python_dataclass(schema_name, schema_def) file_path = output_path / f"{schema_name.lower()}.py" else: raise BettyError(f"Simple generation only supports TypeScript and Python, not {language}") with open(file_path, 'w') as f: f.write(content) files_generated.append(str(file_path)) logger.info(f"Generated {file_path}") return { "models_path": str(output_path), "files_generated": files_generated, "model_count": len(schemas), "generator_used": "simple" } def generate_typescript_interface(name: str, schema: Dict[str, Any]) -> str: """Generate TypeScript interface from schema.""" properties = schema.get("properties") or {} required = schema.get("required", []) lines = [f"export interface {name} {{"] if not properties: lines.append(" // No properties defined") for prop_name, prop_def in properties.items(): prop_type = map_openapi_type_to_typescript(prop_def.get("type", "any")) optional = "" if prop_name in required else "?" description = prop_def.get("description", "") if description: lines.append(f" /** {description} */") lines.append(f" {prop_name}{optional}: {prop_type};") lines.append("}") return "\n".join(lines) def generate_python_dataclass(name: str, schema: Dict[str, Any]) -> str: """Generate Python dataclass from schema.""" properties = schema.get("properties") or {} required = schema.get("required", []) lines = [ "from dataclasses import dataclass", "from typing import Optional", "from datetime import datetime", "", "@dataclass", f"class {name}:" ] if not properties: lines.append(" pass") else: for prop_name, prop_def in properties.items(): prop_type = map_openapi_type_to_python(prop_def) description = prop_def.get("description", "") if prop_name not in required: prop_type = f"Optional[{prop_type}]" if description: lines.append(f" # {description}") default = " = None" if prop_name not in required else "" lines.append(f" {prop_name}: {prop_type}{default}") return "\n".join(lines) def map_openapi_type_to_typescript(openapi_type: str) -> str: """Map OpenAPI type to TypeScript type.""" type_map = { "string": "string", "number": "number", "integer": "number", "boolean": "boolean", "array": "any[]", "object": "object" } return type_map.get(openapi_type, "any") def map_openapi_type_to_python(prop_def: Dict[str, Any]) -> str: """Map OpenAPI type to Python type.""" openapi_type = prop_def.get("type", "Any") format_type = prop_def.get("format", "") if openapi_type == "string": if format_type == "date-time": return "datetime" elif format_type == "uuid": return "str" # or UUID from uuid module return "str" elif openapi_type == "number" or openapi_type == "integer": return "int" if openapi_type == "integer" else "float" elif openapi_type == "boolean": return "bool" elif openapi_type == "array": return "list" elif openapi_type == "object": return "dict" return "Any" def generate_models( spec_path: str, language: str, output_dir: str = "src/models", package_name: str = None ) -> Dict[str, Any]: """ Generate models from API specification. Args: spec_path: Path to specification file language: Target language output_dir: Output directory package_name: Package name Returns: Result dictionary with generated files info Raises: BettyError: If generation fails """ # Validate language if language not in SUPPORTED_LANGUAGES: raise BettyError( f"Unsupported language '{language}'. " f"Supported: {', '.join(SUPPORTED_LANGUAGES)}" ) # Validate spec file exists if not Path(spec_path).exists(): raise BettyError(f"Specification file not found: {spec_path}") logger.info(f"Generating {language} models from {spec_path}") # Try datamodel-code-generator first (most reliable for OpenAPI) try: logger.info("Attempting generation with datamodel-code-generator") result = generate_models_datamodel_code_generator( spec_path, language, output_dir, package_name ) return result except BettyError as e: logger.warning(f"datamodel-code-generator not available: {e}") # Fallback to simple generation logger.info("Using simple built-in generator") result = generate_models_simple( spec_path, language, output_dir, package_name ) return result def main(): parser = argparse.ArgumentParser( description="Generate type-safe models from API specifications using Modelina" ) parser.add_argument( "spec_path", type=str, help="Path to API specification file (OpenAPI or AsyncAPI)" ) parser.add_argument( "language", type=str, choices=SUPPORTED_LANGUAGES, help="Target language for generated models" ) parser.add_argument( "--output-dir", type=str, default="src/models", help="Output directory for generated models (default: src/models)" ) parser.add_argument( "--package-name", type=str, help="Package/module name for generated code" ) args = parser.parse_args() try: # Validate inputs validate_path(args.spec_path) # Generate models result = generate_models( spec_path=args.spec_path, language=args.language, output_dir=args.output_dir, package_name=args.package_name ) # Return structured result output = { "status": "success", "data": result } print(json.dumps(output, indent=2)) except Exception as e: logger.error(f"Model generation failed: {e}") print(json.dumps(format_error_response(e), indent=2)) sys.exit(1) if __name__ == "__main__": main()