#!/usr/bin/env python3 """ Accurate Chatwoot routes.rb parser that handles nested scope/module/namespace properly. The key insight: Chatwoot API routes are structured as: /api/v1/accounts/:account_id/... (nested under resources :accounts) /platform/api/v1/... (separate namespace) /public/api/v1/... (separate namespace) This parser tracks nesting properly to produce full path prefixes. """ import re import sys def parse_routes_rb(filepath): with open(filepath, 'r') as f: content = f.read() lines = content.split('\n') routes = [] # Stack tracks nesting context: [(type, name, path_segment, indent)] # type can be: 'namespace', 'scope_module', 'scope_path', 'resources', 'resource', 'member', 'collection' context_stack = [] def get_prefix(): parts = [] for typ, name, seg, ind in context_stack: if seg: # Only add segments that contribute to path parts.append(seg) return '/' + '/'.join(parts) if parts else '' def get_current_resource(): # Find the most recent resources/resource in stack for typ, name, seg, ind in reversed(context_stack): if typ in ('resources', 'resource'): return name return None def get_resource_is_singular(): for typ, name, seg, ind in reversed(context_stack): if typ == 'resource': return True if typ == 'resources': return False return False def is_in_member(): for typ, name, seg, ind in reversed(context_stack): if typ == 'member': return True if typ == 'collection': return False return False def is_in_collection(): for typ, name, seg, ind in reversed(context_stack): if typ == 'collection': return True if typ == 'member': return False return 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 = len(line) - len(line.lstrip()) # Skip empty lines and comments if not stripped or stripped.startswith('#'): i += 1 continue # ---- NAMESPACE ---- # namespace :name → adds /name to path ns_match = re.match(r'namespace\s+:(\w+)', stripped) if ns_match: name = ns_match.group(1) context_stack.append(('namespace', name, name, indent)) i += 1 continue # namespace :name, module: :other → adds /name to path (module only affects controller) ns_mod_match = re.match(r'namespace\s+:(\w+),\s*module:\s*:(\w+)', stripped) if ns_mod_match: name = ns_mod_match.group(1) context_stack.append(('namespace', name, name, indent)) i += 1 continue # ---- SCOPE ---- # scope module: :name → does NOT add to path, only to controller module lookup scope_mod_match = re.match(r'scope\s+module:\s*:(\w+)', stripped) if scope_mod_match: # Don't add path segment — scope module: only affects controller resolution name = scope_mod_match.group(1) context_stack.append(('scope_module', name, '', indent)) i += 1 continue # scope 'path' → adds path to URL scope_path_match = re.match(r"scope\s+'([^']+)'", stripped) if scope_path_match: seg = scope_path_match.group(1).lstrip('/') context_stack.append(('scope_path', seg, seg, indent)) i += 1 continue # ---- END ---- if stripped == 'end': # Pop context stack — find matching block while context_stack and context_stack[-1][3] >= indent: context_stack.pop() i += 1 continue # ---- RESOURCES / RESOURCE ---- 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') # Path segment for resources: name (plural) or name (singular) # For nested resources under resources :accounts, the path is /accounts/:account_id if is_singular: path_seg = name else: path_seg = name # resources :agents → /agents # Extract only/except only_match = re.search(r'only:\s*\[([^\]]+)\]', stripped) except_match = re.search(r'except:\s*\[([^\]]+)\]', stripped) default_actions = ['show', 'create', 'update', 'destroy'] if is_singular else ['index', 'show', 'create', 'update', 'destroy', 'new', 'edit'] actions = only_match and [a.strip().strip(':').strip("'").strip('"') for a in only_match.group(1).split(',')] or default_actions 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() # Determine if this is nested under another resources (needs :parent_id param) parent_resource = None for typ, pname, pseg, pind in reversed(context_stack): if typ in ('resources', 'resource') and pseg: parent_resource = pname break for action in actions: method = METHOD_MAP.get(action, 'GET') if is_singular: # Singular resource: no :id in path 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)) # Push context for nested sub-routes (member/collection blocks inside) # In Rails, nested resources under `resources :accounts` get /accounts/:account_id prefix # The context stack path segment includes the param for parent resources if is_singular: context_stack.append(('resource', name, name, indent)) else: # resources :accounts → nested routes get /accounts/:account_id prefix singular = name.rstrip('s') if name.endswith('s') else name # Handle special cases if name == 'inboxes': singular = 'inbox' elif name == 'addresses': singular = 'address' elif name == 'csat_survey_responses': singular = 'csat_survey_response' elif name == 'custom_attribute_definitions': singular = 'custom_attribute_definition' elif name == 'notification_subscriptions': singular = 'notification_subscription' elif name == 'assignment_policies': singular = 'assignment_policy' elif name == 'assignment_policies_v2': singular = 'assignment_policy_v2' elif name == 'agent_capacity_policies': singular = 'agent_capacity_policy' elif name == 'custom_filters': singular = 'custom_filter' elif name == 'dashboard_apps': singular = 'dashboard_app' elif name == 'bulk_actions': singular = 'bulk_action' elif name == 'contact_inboxes': singular = 'contact_inbox' elif name == 'canned_responses': singular = 'canned_response' elif name == 'team_members': singular = 'team_member' elif name == 'inbox_members': singular = 'inbox_member' elif name == 'macro': singular = 'macro' elif name == 'notifications': singular = 'notification' elif name == 'labels': singular = 'label' elif name == 'articles': singular = 'article' elif name == 'categories': singular = 'category' elif name == 'companies': singular = 'company' elif name == 'campaigns': singular = 'campaign' elif name == 'conversations': singular = 'conversation' elif name == 'messages': singular = 'message' elif name == 'hooks': singular = 'hook' elif name == 'portals': singular = 'portal' elif name == 'reports': singular = 'report' elif name == 'contacts': singular = 'contact' elif name == 'agents': singular = 'agent' elif name == 'automation_rules': singular = 'automation_rule' elif name == 'agent_bots': singular = 'agent_bot' elif name == 'apps': singular = 'app' elif name == 'assistants': singular = 'assistant' elif name == 'scenarios': singular = 'scenario' elif name == 'documents': singular = 'document' elif name == 'tasks': singular = 'task' context_stack.append(('resources', name, name + '/:' + singular + '_id', indent)) i += 1 continue # ---- MEMBER DO ---- if re.match(r'member\s+do', stripped) or stripped == 'member': context_stack.append(('member', 'member', '', indent)) i += 1 continue # ---- COLLECTION DO ---- if re.match(r'collection\s+do', stripped) or stripped == 'collection': context_stack.append(('collection', 'collection', '', indent)) i += 1 continue # ---- ROUTE DEFINITIONS (inside member/collection blocks) ---- # post :action_name, on: :member inline_member = re.match(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() current_res = get_current_resource() path = prefix + '/:id/' + action_name routes.append((method, path, action_name)) i += 1 continue # post :action_name, on: :collection inline_collection = re.match(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 + '/' + action_name routes.append((method, path, action_name)) i += 1 continue # Inside member/collection block: get/post/etc :action_name 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() current_res = get_current_resource() if is_in_member(): path = prefix + '/:id/' + action_name elif is_in_collection(): path = prefix + '/' + action_name else: # Top-level action path = prefix + '/' + action_name routes.append((method, path, action_name)) i += 1 continue # Direct path definition: get 'path', to: ... 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 routes.append((method, path, '')) i += 1 continue # post 'path', to: ... direct_match2 = re.match(r"(get|post|put|patch|delete)\s+\"([^\"]+)\"", stripped) if direct_match2: method = direct_match2.group(1).upper() path = direct_match2.group(2) prefix = get_prefix() if not path.startswith('/'): path = prefix + '/' + path routes.append((method, path, '')) i += 1 continue i += 1 return routes def main(): routes = parse_routes_rb('/home/yanghao05/Workspace/chatwoot-reference/config/routes.rb') # Filter only API routes api_routes = [(m, p, a) for m, p, a in routes if any(x in p for x in ['/api/', '/platform/', '/public/api/', '/widget/', '/hc/'])] # Deduplicate by (method, path) unique = sorted(set((m, p) for m, p, a in api_routes)) print(f"Total parsed routes: {len(routes)}") print(f"API routes (filtered): {len(api_routes)}") print(f"Unique API routes: {len(unique)}") # Output for comparison for method, path in unique: print(f"{method} {path}") if __name__ == '__main__': main()