Files
gochat/scripts/parse_chatwoot_routes_v3.py
T
2026-06-04 15:44:48 +08:00

323 lines
13 KiB
Python

#!/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()