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.
225 lines
8.4 KiB
Python
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() |