#!/usr/bin/env python3 """ Parse Chatwoot routes.rb to extract all API routes. Outputs a normalized route list comparable to GoChat's dump_routes output. """ import re import sys def parse_routes_rb(filepath): with open(filepath, 'r') as f: content = f.read() lines = content.split('\n') routes = [] # Track namespace nesting with indent levels namespace_stack = [] # [(name, indent_level)] def get_prefix(): return '/' + '/'.join(ns for ns, _ in namespace_stack) if namespace_stack else '' def get_indent(line): return len(line) - len(line.lstrip()) # State tracking for nested blocks current_resource = None current_resource_indent = 0 in_member = False in_collection = False block_indent = 0 resource_is_singular = False METHOD_MAP = { 'index': 'GET', 'show': 'GET', 'create': 'POST', 'update': 'PATCH', 'destroy': 'DELETE', 'new': 'GET', 'edit': 'GET' } i = 0 while i < len(lines): line = lines[i] stripped = line.strip() indent = get_indent(line) # Skip empty lines and comments if not stripped or stripped.startswith('#'): i += 1 continue # namespace :name ns_match = re.match(r'namespace\s+:(\w+)', stripped) if ns_match: namespace_stack.append((ns_match.group(1), indent)) i += 1 continue # scope module: :name (treat as namespace for path) scope_match = re.match(r'scope\s+module:\s+:(\w+)', stripped) if scope_match: # scope module: doesn't add to URL path, only to controller module i += 1 continue # scope '/path' (adds to URL path) scope_path_match = re.match(r'scope\s+[\'"]([^\'"]+)[\'"]', stripped) if scope_path_match: namespace_stack.append((scope_path_match.group(1).lstrip('/'), indent)) i += 1 continue # end — pop namespace stack or nested blocks if stripped == 'end': if in_member or in_collection: in_member = False in_collection = False i += 1 continue if namespace_stack and namespace_stack[-1][1] >= indent: namespace_stack.pop() i += 1 continue # resources / resource declaration res_match = re.match(r'(resources|resource)\s+:([a-zA-Z_]\w*)', stripped) if res_match: kind = res_match.group(1) name = res_match.group(2) is_singular = (kind == 'resource') # Extract only/except constraints only_match = re.search(r'only:\s*\[([^\]]+)\]', stripped) except_match = re.search(r'except:\s*\[([^\]]+)\]', stripped) if only_match: actions = [a.strip().strip(':').strip("'").strip('"') for a in only_match.group(1).split(',')] else: default = ['show', 'create', 'update', 'destroy'] if is_singular else ['index', 'show', 'create', 'update', 'destroy', 'new', 'edit'] actions = default if except_match: excluded = [a.strip().strip(':').strip("'").strip('"') for a in except_match.group(1).split(',')] actions = [a for a in actions if a not in excluded] prefix = get_prefix() for action in actions: method = METHOD_MAP.get(action, 'GET') if is_singular: path = prefix + '/' + name elif action in ['index', 'create', 'new']: path = prefix + '/' + name elif action == 'edit': path = prefix + '/' + name + '/:id/edit' else: path = prefix + '/' + name + '/:id' routes.append((method, path, action, prefix, name)) # Check if next lines contain member/collection/do blocks # For now, set current_resource context current_resource = name current_resource_indent = indent resource_is_singular = is_singular i += 1 continue # member do / member (inline) if re.match(r'member\s+do', stripped) or stripped == 'member': in_member = True block_indent = indent i += 1 continue # collection do / collection (inline) if re.match(r'collection\s+do', stripped) or stripped == 'collection': in_collection = True block_indent = indent i += 1 continue # Route inside member/collection block if in_member or in_collection: action_match = re.match(r'(get|post|put|patch|delete)\s+:(\w+)', stripped) if action_match: method = action_match.group(1).upper() action_name = action_match.group(2) prefix = get_prefix() if in_member: path = prefix + '/' + current_resource + '/:id/' + action_name elif in_collection: path = prefix + '/' + current_resource + '/' + action_name routes.append((method, path, action_name, prefix, current_resource)) i += 1 continue # Direct route: get 'path', to: 'controller#action' direct_match = re.match(r"(get|post|put|patch|delete)\s+'([^']+)'", stripped) if direct_match: method = direct_match.group(1).upper() path = direct_match.group(2) prefix = get_prefix() if not path.startswith('/'): path = prefix + '/' + path else: path = path # absolute path routes.append((method, path, '', prefix, '')) i += 1 continue # Inline member/collection action on same line as resources # e.g. resources :conversations do; member { post :toggle_status } inline_member = re.search(r'(get|post|put|patch|delete)\s+:(\w+),\s*on:\s*:member', stripped) if inline_member: method = inline_member.group(1).upper() action_name = inline_member.group(2) prefix = get_prefix() path = prefix + '/' + (current_resource or 'unknown') + '/:id/' + action_name routes.append((method, path, action_name, prefix, current_resource or 'unknown')) i += 1 continue inline_collection = re.search(r'(get|post|put|patch|delete)\s+:(\w+),\s*on:\s*:collection', stripped) if inline_collection: method = inline_collection.group(1).upper() action_name = inline_collection.group(2) prefix = get_prefix() path = prefix + '/' + (current_resource or 'unknown') + '/' + action_name routes.append((method, path, action_name, prefix, current_resource or 'unknown')) i += 1 continue i += 1 return routes def normalize_path(path): """Normalize route path for comparison — replace specific param names with generic :id""" # Replace :account_id, :conversation_id, etc. with :id return re.sub(r':[a-zA-Z_]+_id', ':id', path) def main(): chatwoot_routes = parse_routes_rb('/home/yanghao05/Workspace/chatwoot-reference/config/routes.rb') # Filter only API routes (under /api or /platform or /public/api) api_routes = [(m, p, a, prefix, name) for m, p, a, prefix, name in chatwoot_routes if any(x in p for x in ['/api/', '/platform/', '/public/api/', '/widget/', '/hc/'])] print(f"Total parsed routes: {len(chatwoot_routes)}") print(f"API routes: {len(api_routes)}") # Deduplicate by (method, path) unique = sorted(set((m, p) for m, p, a, prefix, name in api_routes)) print(f"Unique API routes: {len(unique)}") # Group by prefix by_prefix = {} for m, p, a, prefix, name in api_routes: by_prefix.setdefault(prefix, []).append((m, p)) for prefix in sorted(by_prefix.keys()): print(f"\n--- {prefix} ({len(by_prefix[prefix])} routes) ---") for m, p in sorted(by_prefix[prefix]): print(f" {m} {p}") if __name__ == '__main__': main()