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

225 lines
8.4 KiB
Python

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