#!/usr/bin/env python3 """ Generate API Contract and Shared Types from Design Document This script: 1. Reads the design_document.yml 2. Extracts all API endpoints and their types 3. Generates api_contract.yml with strict typing 4. Generates app/types/api.ts with shared TypeScript interfaces Both frontend and backend agents MUST use these generated files to ensure contract compliance. """ import os import sys import json from pathlib import Path from datetime import datetime from typing import Dict, List, Any, Optional, Set try: import yaml except ImportError: yaml = None def load_yaml(path: Path) -> Dict: """Load YAML file.""" if yaml: with open(path) as f: return yaml.safe_load(f) else: # Fallback: simple YAML parser for basic cases with open(path) as f: content = f.read() # Try JSON first (YAML is superset of JSON) try: return json.loads(content) except: print(f"Warning: yaml module not available, using basic parser", file=sys.stderr) return {} def save_yaml(data: Dict, path: Path) -> None: """Save data as YAML.""" if yaml: with open(path, 'w') as f: yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) else: # Fallback: JSON format with open(path, 'w') as f: json.dump(data, f, indent=2) def ts_type_from_field(field: Dict) -> str: """Convert design document field type to TypeScript type.""" type_map = { 'string': 'string', 'text': 'string', 'integer': 'number', 'float': 'number', 'decimal': 'number', 'boolean': 'boolean', 'datetime': 'Date', 'date': 'Date', 'uuid': 'string', 'json': 'Record', 'array': 'unknown[]', } field_type = field.get('type', 'string') # Handle enum type if field_type == 'enum': enum_values = field.get('enum_values', []) if enum_values: return ' | '.join([f"'{v}'" for v in enum_values]) return 'string' return type_map.get(field_type, 'unknown') def generate_type_from_model(model: Dict) -> Dict: """Generate TypeScript type definition from model.""" type_id = f"type_{model['name']}" properties = [] for field in model.get('fields', []): # Skip internal fields like password_hash if field['name'].endswith('_hash'): continue constraints = field.get('constraints', []) required = 'not_null' in constraints or 'primary_key' in constraints properties.append({ 'name': to_camel_case(field['name']), 'type': ts_type_from_field(field), 'required': required, 'description': field.get('description', ''), }) return { 'id': type_id, 'name': model['name'], 'definition': { 'type': 'object', 'properties': properties, }, 'used_by': { 'models': [model['id']], 'responses': [], 'requests': [], } } def generate_request_type(endpoint: Dict) -> Optional[Dict]: """Generate request body type from endpoint definition.""" request_body = endpoint.get('request_body', {}) if not request_body: return None schema = request_body.get('schema', {}) if not schema: return None # Generate type name from endpoint parts = endpoint['id'].replace('api_', '').split('_') type_name = ''.join([p.capitalize() for p in parts]) + 'Request' type_id = f"type_{type_name}" properties = [] for prop in schema.get('properties', []): properties.append({ 'name': to_camel_case(prop['name']), 'type': ts_type_from_field(prop), 'required': prop.get('required', False), 'description': prop.get('description', ''), 'validation': ','.join(prop.get('validations', [])) if prop.get('validations') else None, }) return { 'id': type_id, 'name': type_name, 'definition': { 'type': 'object', 'properties': properties, }, 'used_by': { 'models': [], 'responses': [], 'requests': [endpoint['id']], } } def generate_response_type(endpoint: Dict, models: Dict[str, Dict]) -> Optional[Dict]: """Generate response type from endpoint definition - may reference model types.""" responses = endpoint.get('responses', []) success_response = None for resp in responses: status = resp.get('status', 0) if 200 <= status < 300: success_response = resp break if not success_response: return None # Check if this response references a model depends_on = endpoint.get('depends_on_models', []) if depends_on: # Response likely uses model type primary_model = depends_on[0] if primary_model in models: return None # Will use model type directly # Generate custom response type schema = success_response.get('schema', {}) if not schema or schema.get('type') != 'object': return None parts = endpoint['id'].replace('api_', '').split('_') type_name = ''.join([p.capitalize() for p in parts]) + 'Response' type_id = f"type_{type_name}" properties = [] for prop in schema.get('properties', []): properties.append({ 'name': to_camel_case(prop['name']), 'type': ts_type_from_field(prop), 'required': True, 'description': '', }) return { 'id': type_id, 'name': type_name, 'definition': { 'type': 'object', 'properties': properties, }, 'used_by': { 'models': [], 'responses': [endpoint['id']], 'requests': [], } } def to_camel_case(snake_str: str) -> str: """Convert snake_case to camelCase.""" components = snake_str.split('_') return components[0] + ''.join(x.capitalize() for x in components[1:]) def generate_endpoint_contract(endpoint: Dict, types: Dict[str, Dict], models: Dict[str, Dict]) -> Dict: """Generate endpoint contract from design document endpoint.""" # Determine request body type request_body = None if endpoint.get('request_body'): # Generate request type name parts = endpoint['id'].replace('api_', '').split('_') type_name = ''.join([p.capitalize() for p in parts]) + 'Request' request_body = { 'type_id': f"type_{type_name}", 'content_type': 'application/json', } # Determine response type response_type_id = None is_array = False responses = endpoint.get('responses', []) success_response = None for resp in responses: if 200 <= resp.get('status', 0) < 300: success_response = resp break if success_response: # Check if referencing a model depends_on = endpoint.get('depends_on_models', []) if depends_on: model_id = depends_on[0] if model_id in models: model_name = models[model_id].get('name', model_id.replace('model_', '').capitalize()) response_type_id = f"type_{model_name}" # Check if response is array schema = success_response.get('schema', {}) if schema.get('type') == 'array': is_array = True if not response_type_id: parts = endpoint['id'].replace('api_', '').split('_') type_name = ''.join([p.capitalize() for p in parts]) + 'Response' response_type_id = f"type_{type_name}" # Extract path params path_params = [] for param in endpoint.get('path_params', []): path_params.append({ 'name': param['name'], 'type': ts_type_from_field(param), 'description': param.get('description', ''), }) # Extract query params query_params = [] for param in endpoint.get('query_params', []): query_params.append({ 'name': param['name'], 'type': ts_type_from_field(param), 'required': param.get('required', False), 'default': param.get('default'), 'description': param.get('description', ''), }) # Build error responses error_responses = [] for resp in responses: status = resp.get('status', 0) if status >= 400: error_responses.append({ 'status': status, 'type_id': 'type_ApiError', 'description': resp.get('description', ''), }) return { 'id': endpoint['id'], 'method': endpoint['method'], 'path': endpoint['path'], 'path_params': path_params, 'query_params': query_params, 'request_body': request_body, 'response': { 'success': { 'status': success_response.get('status', 200) if success_response else 200, 'type_id': response_type_id, 'is_array': is_array, }, 'errors': error_responses, }, 'auth': endpoint.get('auth', {'required': False, 'roles': []}), 'version': '1.0.0', } def generate_frontend_calls(pages: List[Dict], components: List[Dict], endpoints: Dict[str, Dict]) -> List[Dict]: """Generate frontend call contracts from pages and components.""" calls = [] # From pages for page in pages: for data_need in page.get('data_needs', []): api_id = data_need.get('api_id') if api_id and api_id in endpoints: calls.append({ 'id': f"call_{page['id']}_{api_id}", 'source': { 'entity_id': page['id'], 'file_path': f"app{page['path']}/page.tsx", }, 'endpoint_id': api_id, 'purpose': data_need.get('purpose', 'Load data'), 'trigger': 'onLoad' if data_need.get('on_load') else 'onDemand', 'request_mapping': { 'from_props': [], 'from_state': [], 'from_form': [], }, 'response_handling': { 'success_action': 'Update state', 'error_action': 'Show error', }, }) # From components for component in components: for api_id in component.get('uses_apis', []): if api_id in endpoints: endpoint = endpoints[api_id] method = endpoint.get('method', 'GET') trigger = 'onSubmit' if method in ['POST', 'PUT', 'PATCH'] else 'onDemand' calls.append({ 'id': f"call_{component['id']}_{api_id}", 'source': { 'entity_id': component['id'], 'file_path': f"app/components/{component['name']}.tsx", }, 'endpoint_id': api_id, 'purpose': f"Call {api_id}", 'trigger': trigger, 'request_mapping': { 'from_props': [], 'from_state': [], 'from_form': [], }, 'response_handling': { 'success_action': 'Handle response', 'error_action': 'Show error', }, }) return calls def generate_backend_routes(endpoints: List[Dict]) -> List[Dict]: """Generate backend route contracts from endpoints.""" routes = [] for endpoint in endpoints: # Determine file path from endpoint path api_path = endpoint['path'].replace('/api/', '') # Handle dynamic segments like /users/:id parts = api_path.split('/') file_parts = [] for part in parts: if part.startswith(':'): file_parts.append(f"[{part[1:]}]") else: file_parts.append(part) file_path = f"app/api/{'/'.join(file_parts)}/route.ts" routes.append({ 'id': f"route_{endpoint['method'].lower()}_{api_path.replace('/', '_')}", 'endpoint_id': endpoint['id'], 'file_path': file_path, 'export_name': endpoint['method'], 'uses_models': endpoint.get('depends_on_models', []), 'uses_services': [], 'must_validate': [], 'must_authenticate': endpoint.get('auth', {}).get('required', False), 'must_authorize': endpoint.get('auth', {}).get('roles', []), }) return routes def generate_typescript_types(types: List[Dict]) -> str: """Generate TypeScript type definitions.""" lines = [ "// AUTO-GENERATED - DO NOT EDIT", "// Source: .workflow/versions/vXXX/contracts/api_contract.yml", f"// Generated: {datetime.now().isoformat()}", "", "// ============================================================================", "// Shared API Types", "// Both frontend and backend MUST import from this file", "// ============================================================================", "", ] # Add standard error types lines.extend([ "// === Error Types ===", "", "export interface ApiError {", " error: string;", " message?: string;", " code?: string;", "}", "", "export interface ValidationError {", " error: string;", " details: string[];", "}", "", ]) # Generate interfaces for each type lines.append("// === Domain Types ===") lines.append("") for type_def in types: name = type_def['name'] definition = type_def['definition'] if definition['type'] == 'object': lines.append(f"export interface {name} {{") for prop in definition.get('properties', []): optional = '' if prop.get('required') else '?' desc = prop.get('description', '') if desc: lines.append(f" /** {desc} */") lines.append(f" {prop['name']}{optional}: {prop['type']};") lines.append("}") lines.append("") elif definition['type'] == 'enum': values = definition.get('enum_values', []) quoted_values = [f"'{v}'" for v in values] lines.append(f"export type {name} = {' | '.join(quoted_values)};") lines.append("") elif definition['type'] == 'union': members = definition.get('union_members', []) lines.append(f"export type {name} = {' | '.join(members)};") lines.append("") return '\n'.join(lines) def generate_api_paths(endpoints: List[Dict]) -> str: """Generate API path constants for type-safe calls.""" lines = [ "", "// === API Paths ===", "", "export const API_PATHS = {", ] for endpoint in endpoints: # Generate constant name: api_get_users -> GET_USERS const_name = endpoint['id'].replace('api_', '').upper() lines.append(f" {const_name}: '{endpoint['path']}' as const,") lines.append("} as const;") lines.append("") return '\n'.join(lines) def main(): """Main entry point.""" if len(sys.argv) < 2: print("Usage: generate_api_contract.py [--output-dir ]", file=sys.stderr) sys.exit(1) design_doc_path = Path(sys.argv[1]) # Parse output directory output_dir = design_doc_path.parent.parent # .workflow/versions/vXXX/ if '--output-dir' in sys.argv: idx = sys.argv.index('--output-dir') output_dir = Path(sys.argv[idx + 1]) if not design_doc_path.exists(): print(f"Error: Design document not found: {design_doc_path}", file=sys.stderr) sys.exit(1) # Load design document design_doc = load_yaml(design_doc_path) if not design_doc: print("Error: Failed to load design document", file=sys.stderr) sys.exit(1) # Extract entities models = {m['id']: m for m in design_doc.get('data_models', [])} endpoints = design_doc.get('api_endpoints', []) pages = design_doc.get('pages', []) components = design_doc.get('components', []) workflow_version = design_doc.get('workflow_version', 'unknown') revision = design_doc.get('revision', 1) # Generate types from models types = [] for model in models.values(): type_def = generate_type_from_model(model) types.append(type_def) # Generate request/response types from endpoints endpoints_dict = {e['id']: e for e in endpoints} for endpoint in endpoints: req_type = generate_request_type(endpoint) if req_type: types.append(req_type) resp_type = generate_response_type(endpoint, models) if resp_type: types.append(resp_type) # Generate types dictionary for lookup types_dict = {t['id']: t for t in types} # Generate endpoint contracts endpoint_contracts = [] for endpoint in endpoints: contract = generate_endpoint_contract(endpoint, types_dict, models) endpoint_contracts.append(contract) # Generate frontend calls frontend_calls = generate_frontend_calls(pages, components, endpoints_dict) # Generate backend routes backend_routes = generate_backend_routes(endpoints) # Build API contract api_contract = { 'api_contract': { 'workflow_version': workflow_version, 'design_document_revision': revision, 'generated_at': datetime.now().isoformat(), 'validated_at': None, 'status': 'draft', }, 'types': types, 'endpoints': endpoint_contracts, 'frontend_calls': frontend_calls, 'backend_routes': backend_routes, } # Create output directories contracts_dir = output_dir / 'contracts' contracts_dir.mkdir(parents=True, exist_ok=True) # Save API contract contract_path = contracts_dir / 'api_contract.yml' save_yaml(api_contract, contract_path) print(f"Generated: {contract_path}") # Generate TypeScript types ts_types = generate_typescript_types(types) ts_paths = generate_api_paths(endpoint_contracts) # Find project root (look for package.json) project_root = output_dir while project_root != project_root.parent: if (project_root / 'package.json').exists(): break project_root = project_root.parent if not (project_root / 'package.json').exists(): project_root = output_dir.parent.parent.parent # Assume .workflow is in project root # Create types directory and file types_dir = project_root / 'app' / 'types' types_dir.mkdir(parents=True, exist_ok=True) types_file = types_dir / 'api.ts' types_file.write_text(ts_types + ts_paths) print(f"Generated: {types_file}") # Summary print("\n=== API CONTRACT GENERATED ===") print(f"Types: {len(types)}") print(f"Endpoints: {len(endpoint_contracts)}") print(f"Frontend calls: {len(frontend_calls)}") print(f"Backend routes: {len(backend_routes)}") print(f"\nContract file: {contract_path}") print(f"Types file: {types_file}") print("\nBoth agents MUST import from app/types/api.ts") if __name__ == '__main__': main()