Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
323 lines
13 KiB
Python
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() |