import json import re import os from pathlib import Path from typing import Dict, List, Any import argparse import sys import logging from datetime import datetime import inflect class SwiftModelGenerator: def __init__(self, json_data: Dict[str, Any]): self.data = json_data self.pluralizer = inflect.engine() def generate_model(self, model_data: Dict[str, Any]) -> str: model_name = model_data["name"] is_sync = model_data.get("synchronizable", False) is_observable = model_data.get("observable", False) properties = model_data["properties"] # Get protocol specific configurations resource = self.make_resource_name(model_name) resource_name = model_data.get("resource_name", resource) token_exempted = model_data.get("tokenExemptedMethods", []) filter_by_store = model_data.get("filterByStoreIdentifier", False) lines = ["// Generated by SwiftModelGenerator", "// Do not modify this file manually", ""] # Import statement lines.append("import Foundation") lines.append("import LeStorage") if is_observable: lines.append("import SwiftUI") lines.append("") # Class declaration if is_observable: lines.append("@Observable") protocol = "SyncedStorable" if is_sync else "Storable" lines.append(f"class Base{model_name}: ModelObject, {protocol}, Codable {{") lines.append("") # Add SyncedStorable protocol requirements lines.extend(self._generate_protocol_requirements(resource_name, token_exempted, filter_by_store)) lines.append("") # Properties for prop in properties: swift_type = prop["type"] if prop.get("optional", False): swift_type += "?" default_value = prop.get("defaultValue", "nil" if prop.get("optional", False) else self._get_default_value(swift_type)) lines.append(f" var {prop['name']}: {swift_type} = {default_value}") lines.append("") # Add constructor lines.extend(self._generate_constructor(model_name, properties)) lines.append("") # CodingKeys lines.extend(self._generate_coding_keys(properties, is_observable)) lines.append("") # Encryption methods encrypted_props = [p for p in properties if "encryption" in p] if encrypted_props: lines.extend(self._generate_encryption_methods(properties)) lines.append("") # Codable implementation lines.extend(self._generate_decoder(model_name, properties, is_observable)) lines.append("") lines.extend(self._generate_encoder(properties, is_observable)) lines.append("") # Foreign Key convenience foreign_key_methods = self._generate_foreign_key_methods(properties) if foreign_key_methods: lines.extend(foreign_key_methods) # lines.append("") # Copy method lines.extend(self._generate_copy_method(model_name, properties)) lines.append("") # Add relationships function lines.extend(self._generate_relationships(model_name, properties)) lines.append("") lines.append("}") return "\n".join(lines) def _generate_constructor(self, model_name: str, properties: List[Dict[str, Any]]) -> List[str]: """Generate a constructor with all properties as parameters with default values.""" lines = [" init("] # Generate parameter list params = [] for prop in properties: name = prop['name'] type_name = prop['type'] is_optional = prop.get("optional", False) # Always include a default value default_value = prop.get("defaultValue") if default_value is None: if is_optional: default_value = "nil" else: default_value = self._get_default_value(type_name) # Format the parameter with its type and default value param = f" {name}: {type_name}" if is_optional: param += "?" param += f" = {default_value}" params.append(param) # Join parameters with commas lines.extend([f"{param}," for param in params[:-1]]) lines.append(f"{params[-1]}") # Last parameter without comma # Constructor body lines.extend([ " ) {", " super.init()", ]) # Property assignments for prop in properties: name = prop['name'] lines.append(f" self.{name} = {name}") lines.append(" }") return lines def _generate_foreign_key_methods(self, properties: List[Dict[str, Any]]) -> List[str]: lines = [] for prop in properties: if "foreignKey" in prop: foreign_key = prop["foreignKey"] prop_name = prop["name"] method_name = f"{prop_name}Value" is_optional = prop.get("optional", False) lines.extend([f" func {method_name}() -> {foreign_key.rstrip('*')}? {{"]) if is_optional: lines.extend([ f" guard let {prop_name} = self.{prop_name} else {{ return nil }}" ]) if foreign_key.endswith("*"): foreign_key = foreign_key[:-1] # Remove the asterisk lines.extend([ f" return self.store?.findById({prop_name})" ]) else: lines.extend([ f" return Store.main.findById({prop_name})" ]) lines.extend([" }", ""]) # Close the method and add a blank line return lines def _generate_coding_keys(self, properties: List[Dict[str, Any]], is_observable: bool) -> List[str]: lines = [" enum CodingKeys: String, CodingKey {"] for prop in properties: name = prop['name'] # Add underscore prefix to case name if observable, but keep the string value without underscore case_name = f"_{name}" if is_observable else name lines.append(f" case {case_name} = \"{name}\"") lines.append(" }") return lines def _generate_encryption_methods(self, properties: List[Dict[str, Any]]) -> List[str]: lines = [] for prop in properties: if "encryption" in prop: name = prop['name'] enc_type = prop['encryption'] if enc_type == "tournament_payment": lines.extend([ f" private static func _decode{name.capitalize()}(container: KeyedDecodingContainer) throws -> TournamentPayment? {{", f" let data = try container.decodeIfPresent(Data.self, forKey: ._{name})", " ", " if let data {", " do {", " let decoded: String = try data.decryptData(pass: CryptoKey.pass.rawValue)", " let sequence = decoded.compactMap { NumberFormatter.standard.number(from: String($0))?.intValue }", " return TournamentPayment(rawValue: sequence[18])", " } catch {", " Logger.error(error)", " }", " }", " return nil", " }", "", f" private func _encode{name.capitalize()}(container: inout KeyedEncodingContainer) throws {{", f" guard let {name} else {{", f" try container.encodeNil(forKey: ._{name})", " return", " }", " ", " let max: Int = TournamentPayment.allCases.count", " var sequence = (1...18).map { _ in Int.random(in: (0..) throws -> Bool {{", f" let data = try container.decodeIfPresent(Data.self, forKey: ._{name})", " if let data {", " do {", " let decoded: String = try data.decryptData(pass: CryptoKey.pass.rawValue)", " let sequence = decoded.compactMap { NumberFormatter.standard.number(from: String($0))?.intValue }", " return Bool.decodeInt(sequence[18])", " } catch {", " Logger.error(error)", " }", " }", " return false", " }", "", f" private func _encode{name.capitalize()}(container: inout KeyedEncodingContainer) throws {{", " let max: Int = 9", " var sequence = (1...18).map { _ in Int.random(in: (0...max)) }", f" sequence.append(self.{name}.encodedValue)", " sequence.append(contentsOf: (1...13).map { _ in Int.random(in: (0...max ))} )", " ", " let stringCombo: [String] = sequence.map { $0.formatted() }", " let joined: String = stringCombo.joined(separator: \"\")", " if let data = joined.data(using: .utf8) {", " let encryped: Data = try data.encrypt(pass: CryptoKey.pass.rawValue)", f" try container.encode(encryped, forKey: ._{name})", " }", " }" ]) return lines def _generate_decoder(self, model_name: str, properties: List[Dict[str, Any]], is_observable: bool) -> List[str]: lines = [" required init(from decoder: Decoder) throws {", " super.init()", " let container = try decoder.container(keyedBy: CodingKeys.self)"] for prop in properties: name = prop['name'] type_name = prop['type'] is_optional = prop.get("optional", False) default_value = prop.get("defaultValue", "nil" if is_optional else self._get_default_value(type_name)) option = prop.get("option") # Use the correct case reference based on observable flag case_ref = f"_{name}" if is_observable else name if "encryption" in prop: enc_type = prop['encryption'] if enc_type == "standard": if is_optional: lines.append(f" self.{name} = try container.decodeEncryptedIfPresent(key: .{case_ref})") else: lines.append(f" self.{name} = try container.decodeEncrypted(key: .{case_ref})") elif enc_type in ["tournament_payment", "tournament_iscanceled"]: lines.append(f" self.{name} = try Self._decode{name.capitalize()}(container: container)") else: # Handle Date with fractional option if type_name == "Date" and option == "fractional": if is_optional: lines.append(f" if let dateString = try container.decodeIfPresent(String.self, forKey: .{case_ref}) {{") lines.append(f" self.{name} = Date.iso8601FractionalFormatter.date(from: dateString)") lines.append(" } else {") lines.append(f" self.{name} = {default_value}") lines.append(" }") else: lines.append(f" let dateString = try container.decode(String.self, forKey: .{case_ref})") lines.append(f" self.{name} = Date.iso8601FractionalFormatter.date(from: dateString) ?? {default_value}") else: lines.append(f" self.{name} = try container.decodeIfPresent({type_name}.self, forKey: .{case_ref}) ?? {default_value}") lines.append(" }") return lines def _generate_encoder(self, properties: List[Dict[str, Any]], is_observable: bool) -> List[str]: lines = [" func encode(to encoder: Encoder) throws {", " var container = encoder.container(keyedBy: CodingKeys.self)"] for prop in properties: name = prop['name'] type_name = prop['type'] is_optional = prop.get('optional', False) option = prop.get("option") # Use the correct case reference based on observable flag case_ref = f"_{name}" if is_observable else name if "encryption" in prop: enc_type = prop['encryption'] if enc_type == "standard": if is_optional: lines.append(f" try container.encodeAndEncryptIfPresent(self.{name}?.data(using: .utf8), forKey: .{case_ref})") else: lines.append(f" try container.encodeAndEncryptIfPresent(self.{name}.data(using: .utf8), forKey: .{case_ref})") elif enc_type in ["tournament_payment", "tournament_iscanceled"]: lines.append(f" try _encode{name.capitalize()}(container: &container)") else: if type_name == "Date" and option == "fractional": if is_optional: lines.append(f" if let date = self.{name} {{") lines.append(f" try container.encode(Date.iso8601FractionalFormatter.string(from: date), forKey: .{case_ref})") lines.append(" } else {") lines.append(f" try container.encodeNil(forKey: .{case_ref})") lines.append(" }") else: lines.append(f" try container.encode(Date.iso8601FractionalFormatter.string(from: self.{name}), forKey: .{case_ref})") else: lines.append(f" try container.encode(self.{name}, forKey: .{case_ref})") lines.append(" }") return lines def _generate_copy_method(self, model_name: str, properties: List[Dict[str, Any]]) -> List[str]: model_variable = model_name.lower() lines = [f" func copy(from other: any Storable) {{"] lines.append(f" guard let {model_variable} = other as? Base{model_name} else {{ return }}") for prop in properties: name = prop['name'] lines.append(f" self.{name} = {model_variable}.{name}") lines.append(" }") return lines def _generate_protocol_requirements(self, resource_name: str, token_exempted: List[str], filter_by_store: bool) -> List[str]: """Generate the static functions required by SyncedStorable protocol.""" # Convert HTTP methods to proper format formatted_methods = [f".{method.lower()}" for method in token_exempted] methods_str = ", ".join(formatted_methods) if formatted_methods else "" return [ f" static func resourceName() -> String {{ return \"{resource_name}\" }}", f" static func tokenExemptedMethods() -> [HTTPMethod] {{ return [{methods_str}] }}", f" static func filterByStoreIdentifier() -> Bool {{ return {str(filter_by_store).lower()} }}" ] def _generate_relationships(self, model_name, properties: List[Dict[str, Any]]) -> List[str]: # Find all properties with foreign keys foreign_key_props = [p for p in properties if "foreignKey" in p] if not foreign_key_props: # If no foreign keys, return empty array return [ " static func relationships() -> [Relationship] {", " return []", " }" ] lines = [ " static func relationships() -> [Relationship] {", " return [" ] # Generate relationship entries for prop in foreign_key_props: name = prop["name"] foreign_key = prop["foreignKey"].rstrip('*') # Remove asterisk if present lines.append(f" Relationship(type: {foreign_key}.self, keyPath: \\Base{model_name}.{name}),") # Close the array and function lines.extend([ " ]", " }" ]) return lines def _get_default_value(self, type_name: str) -> str: """Get default value for non-optional types""" if "String" in type_name: return "\"\"" elif "Int" in type_name: return "0" elif "Double" in type_name: return "0.0" elif "Bool" in type_name: return "false" elif "Date" in type_name: return "Date()" else: return "nil" # For any other type def get_output_filename(self, model_name: str) -> str: """Generate the output filename for a model.""" return f"Base{model_name}.swift" def make_resource_name(self, text): p = inflect.engine() # Split camelCase into words words = re.findall('[A-Z][^A-Z]*', text) if not words: words = [text] words = [word.lower() for word in words] words[-1] = p.plural(words[-1]) return '-'.join(words) def process_directory(input_dir: str, output_dir: str, logger: logging.Logger, dry_run: bool = False) -> int: """Process all JSON files in the input directory and generate Swift models.""" try: input_path = validate_directory(input_dir) if not dry_run: output_path = validate_directory(output_dir, create=True) json_files = list(input_path.glob("*.json")) if not json_files: logger.warning(f"No JSON files found in '{input_dir}'") return 0 logger.info(f"Found {len(json_files)} JSON files to process") successful_files = 0 for json_file in json_files: try: with open(json_file, 'r') as f: json_data = json.load(f) generator = SwiftModelGenerator(json_data) # Generate each model in the JSON file for model in json_data["models"]: model_name = model["name"] swift_code = generator.generate_model(model) if dry_run: logger.info(f"Would generate Base{model_name}.swift") continue # Write to output file with Base prefix output_file = output_path / generator.get_output_filename(model_name) with open(output_file, 'w') as f: f.write(swift_code) logger.info(f"Generated Base{model_name}.swift") successful_files += 1 except json.JSONDecodeError as e: logger.error(f"Error parsing {json_file.name}: {e}") except KeyError as e: logger.error(f"Missing required key in {json_file.name}: {e}") except Exception as e: logger.error(f"Error processing {json_file.name}: {e}") return successful_files except Exception as e: logger.error(f"Fatal error: {e}") return 0 def setup_logging(verbose: bool) -> logging.Logger: """Configure logging based on verbosity level.""" logger = logging.getLogger('SwiftModelGenerator') handler = logging.StreamHandler() if verbose: logger.setLevel(logging.DEBUG) handler.setFormatter(logging.Formatter( '%(asctime)s - %(levelname)s - %(message)s' )) else: logger.setLevel(logging.INFO) handler.setFormatter(logging.Formatter('%(message)s')) logger.addHandler(handler) return logger def validate_directory(path: str, create: bool = False) -> Path: """Validate and optionally create a directory.""" dir_path = Path(path) if dir_path.exists(): if not dir_path.is_dir(): raise ValueError(f"'{path}' exists but is not a directory") elif create: dir_path.mkdir(parents=True) else: raise ValueError(f"Directory '{path}' does not exist") return dir_path def main(): parser = argparse.ArgumentParser( description="Generate Swift model classes from JSON definitions", epilog="Example: %(prog)s -i ./model_definitions -o ../MyProject/Models" ) parser.add_argument( "-i", "--input-dir", required=True, help="Directory containing JSON model definitions" ) parser.add_argument( "-o", "--output-dir", required=True, help="Directory where Swift files will be generated" ) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable verbose output" ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be generated without actually creating files" ) parser.add_argument( "--version", action="version", version="%(prog)s 1.0.0" ) args = parser.parse_args() # Setup logging logger = setup_logging(args.verbose) # Process the directories start_time = datetime.now() logger.info("Starting model generation...") successful_files = process_directory( args.input_dir, args.output_dir, logger, args.dry_run ) # Report results duration = datetime.now() - start_time logger.info(f"\nGeneration completed in {duration.total_seconds():.2f} seconds") logger.info(f"Successfully processed {successful_files} files") # Return appropriate exit code sys.exit(0 if successful_files > 0 else 1) if __name__ == "__main__": main()