Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
207 lines
6.8 KiB
Python
207 lines
6.8 KiB
Python
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from typing import List, Optional, Set
|
|
|
|
BASE_DIR = Path(__file__).resolve().parents[1]
|
|
|
|
@dataclass
|
|
class RouteRecord:
|
|
edition: str
|
|
route_path: str
|
|
methods: List[str]
|
|
route_file: str
|
|
controller: str
|
|
controller_import: str
|
|
controller_file: str
|
|
|
|
|
|
def detect_methods(source: str) -> List[str]:
|
|
method_matches: Set[str] = set()
|
|
for match in re.findall(r"export\s+(?:const|async\s+function|function)\s+([A-Z]+)\b", source):
|
|
method_matches.add(match.upper())
|
|
for block in re.findall(r"export\s*{\s*([^}]*)}", source):
|
|
tokens = [token.strip() for token in block.split(',')]
|
|
for token in tokens:
|
|
if not token:
|
|
continue
|
|
if ' as ' in token:
|
|
token = token.rsplit(' as ', 1)[-1].strip()
|
|
if token.isupper():
|
|
method_matches.add(token)
|
|
for block in re.findall(r"export\s+const\s+{\s*([^}]*)}", source):
|
|
tokens = [token.strip() for token in block.split(',')]
|
|
for token in tokens:
|
|
if token.isupper():
|
|
method_matches.add(token)
|
|
return sorted(method_matches)
|
|
|
|
|
|
def detect_controller(source: str) -> str:
|
|
controller_match = re.search(r"new\s+(\w+Controller)\s*\(", source)
|
|
if controller_match:
|
|
return controller_match.group(1)
|
|
handler_match = re.search(r"class\s+(\w+Controller)\s+extends", source)
|
|
if handler_match:
|
|
return handler_match.group(1)
|
|
return ""
|
|
|
|
|
|
def detect_controller_import(source: str) -> str:
|
|
import_match = re.search(r"from\s+'([^']*controllers[^']*)'", source)
|
|
if import_match:
|
|
return import_match.group(1)
|
|
import_match = re.search(r'from\s+"([^"]*controllers[^"]*)"', source)
|
|
if import_match:
|
|
return import_match.group(1)
|
|
return ""
|
|
|
|
|
|
def resolve_controller_file(import_path: str, route_file: Path) -> str:
|
|
if not import_path:
|
|
return ""
|
|
|
|
candidates: List[Path] = []
|
|
|
|
if import_path.startswith('@/'):
|
|
rel = import_path[2:]
|
|
candidates.append(BASE_DIR / 'server' / 'src' / rel)
|
|
elif import_path.startswith('@ee/'):
|
|
rel = import_path[4:]
|
|
candidates.append(BASE_DIR / 'ee' / 'server' / 'src' / rel)
|
|
elif import_path.startswith('~/'):
|
|
rel = import_path[2:]
|
|
candidates.append(BASE_DIR / 'server' / 'src' / rel)
|
|
elif import_path.startswith('./') or import_path.startswith('../'):
|
|
candidates.append((route_file.parent / import_path).resolve())
|
|
else:
|
|
candidates.append(BASE_DIR / import_path)
|
|
|
|
extension_candidates = ['.ts', '.tsx', '.js', '.mjs', '.cjs']
|
|
|
|
def existing_path(base: Path) -> Optional[Path]:
|
|
if base.is_file():
|
|
return base
|
|
if base.suffix:
|
|
if base.exists():
|
|
return base
|
|
for ext in extension_candidates:
|
|
candidate = base.with_suffix(ext)
|
|
if candidate.exists():
|
|
return candidate
|
|
# Support index files in directories
|
|
for ext in extension_candidates:
|
|
candidate = base / f'index{ext}'
|
|
if candidate.exists():
|
|
return candidate
|
|
return None
|
|
|
|
for base in candidates:
|
|
resolved = existing_path(base)
|
|
if resolved and BASE_DIR in resolved.parents:
|
|
return str(resolved.relative_to(BASE_DIR))
|
|
return ""
|
|
|
|
|
|
def next_path_to_openapi(rel_path: Path) -> str:
|
|
segments = ['api']
|
|
for segment in rel_path.parts:
|
|
if segment in {'route.ts', 'route.tsx', 'route'}:
|
|
continue
|
|
if segment.startswith('(') and segment.endswith(')'):
|
|
# Next.js "group" segment, skip in route path
|
|
continue
|
|
if segment.startswith('['):
|
|
# Normalize dynamic segments to OpenAPI-style parameters
|
|
raw = segment
|
|
while raw.startswith('[') and raw.endswith(']'):
|
|
raw = raw[1:-1]
|
|
raw = raw.removeprefix('...').removeprefix('..')
|
|
if not raw:
|
|
raw = 'param'
|
|
segment = '{' + raw + '}'
|
|
segments.append(segment)
|
|
return '/' + '/'.join(segments)
|
|
|
|
|
|
def collect_records(base: Path, edition: str) -> List[RouteRecord]:
|
|
records: List[RouteRecord] = []
|
|
for dirpath, _, filenames in os.walk(base):
|
|
for filename in filenames:
|
|
if filename not in {'route.ts', 'route.tsx'}:
|
|
continue
|
|
route_file_path = Path(dirpath, filename)
|
|
rel = route_file_path.relative_to(base)
|
|
with route_file_path.open('r', encoding='utf-8') as fh:
|
|
source = fh.read()
|
|
methods = detect_methods(source)
|
|
controller = detect_controller(source)
|
|
controller_import = detect_controller_import(source)
|
|
controller_file = resolve_controller_file(controller_import, route_file_path)
|
|
route_path = next_path_to_openapi(rel)
|
|
records.append(
|
|
RouteRecord(
|
|
edition=edition,
|
|
route_path=route_path,
|
|
methods=methods,
|
|
route_file=str(route_file_path.relative_to(BASE_DIR)),
|
|
controller=controller,
|
|
controller_import=controller_import,
|
|
controller_file=controller_file,
|
|
)
|
|
)
|
|
records.sort(key=lambda r: (r.route_path, r.edition))
|
|
return records
|
|
|
|
|
|
def main() -> None:
|
|
targets = [
|
|
('CE', BASE_DIR / 'server' / 'src' / 'app' / 'api'),
|
|
('EE', BASE_DIR / 'ee' / 'server' / 'src' / 'app' / 'api'),
|
|
]
|
|
records: List[RouteRecord] = []
|
|
for edition, base in targets:
|
|
if base.exists():
|
|
records.extend(collect_records(base, edition))
|
|
|
|
output_dir = BASE_DIR / 'docs' / 'openapi'
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
json_path = output_dir / 'route-inventory.json'
|
|
csv_path = output_dir / 'route-inventory.csv'
|
|
|
|
with json_path.open('w', encoding='utf-8') as jf:
|
|
json.dump([asdict(r) for r in records], jf, indent=2)
|
|
jf.write('\n')
|
|
|
|
with csv_path.open('w', encoding='utf-8', newline='') as cf:
|
|
writer = csv.writer(cf)
|
|
writer.writerow([
|
|
'edition',
|
|
'route_path',
|
|
'methods',
|
|
'route_file',
|
|
'controller',
|
|
'controller_import',
|
|
'controller_file',
|
|
])
|
|
for record in records:
|
|
writer.writerow([
|
|
record.edition,
|
|
record.route_path,
|
|
' '.join(record.methods),
|
|
record.route_file,
|
|
record.controller,
|
|
record.controller_import,
|
|
record.controller_file,
|
|
])
|
|
|
|
print(f"Wrote {len(records)} routes to {json_path} and {csv_path}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|