Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user