#!/usr/bin/env python3 """ Sprint 28: api-reference.md generator (улучшенная версия). Sprint 24's `/tmp/gen-api-ref.py` пропускал endpoint'ы с nested generic return-типами (например `Task>>`), поэтому в `api-reference.md` было 195 endpoint'ов вместо реальных 240+. Новая стратегия: вместо строгого regex для return-type'a — двухпроходный скан: 1. Найти все [HttpX...] attributes. 2. Для каждого — взять следующую `public` action method ниже. 3. Извлечь route из HttpX и метода. Output: docs/api-reference.md (тот же формат, что Sprint 24). Usage: python3 scripts/gen-api-reference.py """ from __future__ import annotations import re import sys from pathlib import Path from collections import defaultdict REPO = Path(__file__).resolve().parent.parent ROOT = REPO / 'src' / 'food-market.api' / 'Controllers' OUT = REPO / 'docs' / 'api-reference.md' # Регексы. ROUTE_RX = re.compile(r'\[Route\("([^"]+)"\)\]') CLASS_RX = re.compile(r'class\s+(\w+Controller)') HTTP_LINE_RX = re.compile( r'^[ \t]*\[Http(?PGet|Post|Put|Delete|Patch)(?:\("(?P[^"]*)"\))?(?:[^\]]*)?\]' r'(?P(?:[ \t]*,[ \t]*\[(?:Authorize|RequiresPermission|AllowAnonymous|Consumes|RequestSizeLimit|FromBody|ProducesResponseType)[^\]]*\])*)', re.MULTILINE, ) PERM_RX = re.compile(r'\[RequiresPermission\("([^"]+)"\)\]') AUTHORIZE_RX = re.compile(r'\[Authorize(?:\(([^)]+)\))?\]') SUMMARY_RX = re.compile(r'\s*(.*?)\s*', re.DOTALL) PUBLIC_METHOD_RX = re.compile(r'^[ \t]*public\s+', re.MULTILINE) WS_RX = re.compile(r'\s+') def extract_class_info(txt: str) -> tuple[str, str]: """Return (base_route, class_name) for the *first* Controller class.""" base = '' m = ROUTE_RX.search(txt) if m: base = m.group(1) cm = CLASS_RX.search(txt) cname = cm.group(1) if cm else None return base, cname def find_doc_summary(txt: str, attr_pos: int) -> str: """Walk backwards from attribute position to find /// block.""" # Look up to 2000 chars back for /// lines preceding this attr. start = max(0, attr_pos - 2000) pre = txt[start:attr_pos] # The last block of consecutive /// lines. lines = pre.splitlines() doc_lines: list[str] = [] for line in reversed(lines): stripped = line.strip() if stripped.startswith('///'): doc_lines.insert(0, stripped[3:].lstrip()) elif doc_lines: break elif stripped == '': # allow blank-line gap of 1; skip until we hit '///' or non-blank continue else: break if not doc_lines: return '' doc_text = '\n'.join(doc_lines) sm = SUMMARY_RX.search(doc_text) if not sm: return '' s = sm.group(1) s = re.sub(r'<[^>]+>', '', s) return WS_RX.sub(' ', s).strip() def find_attr_block(txt: str, http_match: re.Match) -> str: """Collect the full multi-attr block from the HttpX attribute downward until we hit `public`. Captures additional [Authorize(...)] / [RequiresPermission(...)] that may be on subsequent lines.""" start = http_match.start() # Walk forward from start until we see `public` on a fresh line. pos = start block = [] while pos < len(txt): # Read until end of line eol = txt.find('\n', pos) if eol == -1: break line = txt[pos:eol + 1] block.append(line) next_line = txt[eol + 1: txt.find('\n', eol + 1) if txt.find('\n', eol + 1) != -1 else len(txt)] if re.match(r'\s*public\s+', next_line): break pos = eol + 1 return ''.join(block) def main() -> int: endpoints: list[tuple[str, str, str, str, str, str]] = [] seen_classes: dict[str, str] = {} # cname -> base for fp in sorted(ROOT.rglob('*.cs')): txt = fp.read_text(encoding='utf-8', errors='ignore') base, cname = extract_class_info(txt) if not cname: continue seen_classes[cname] = base for m in HTTP_LINE_RX.finditer(txt): verb = m.group('verb').upper() sub = m.group('sub') or '' # Extract permission/authorize from the attribute block. block = find_attr_block(txt, m) perm_m = PERM_RX.search(block) perm = perm_m.group(1) if perm_m else '' if not perm: auth_m = AUTHORIZE_RX.search(block) if auth_m: perm = f'auth:{auth_m.group(1) or "any"}' # Compose full route. # ASP.NET Core convention: `~/path` в HttpX-атрибуте означает # "absolute from root, ignore class-level [Route]". Срезаем `~` # и используем sub как абсолютный путь. if sub.startswith('~/'): full = sub[1:] # `~/connect/token` → `/connect/token` else: parts = [p.strip('/') for p in (base, sub) if p] full = '/' + '/'.join(parts) full = full.rstrip('/') or '/' summary = find_doc_summary(txt, m.start()) endpoints.append((cname, base, verb, full, perm, summary)) by_ctrl: dict[str, list] = defaultdict(list) for e in endpoints: by_ctrl[e[0]].append(e) out: list[str] = [] out.append('# API endpoint reference') out.append('') out.append('Сгенерировано Python-сканером (`scripts/gen-api-reference.py`) из `src/food-market.api/Controllers/`.') out.append('Sprint 28 версия: ловит endpoint\'ы с nested generic return-типами.') out.append('Идентичный логике runtime-job `ApiReferenceDocsJob` (Sprint 24); тот пересоздаёт файл') out.append('еженедельно при cron `Hangfire:Cron:ApiReferenceDocs`.') out.append('') out.append(f"Всего endpoint'ов: **{len(endpoints)}**. ") out.append(f"Контроллеров: **{len(by_ctrl)}**.") out.append('') out.append('Полная OpenAPI-спека: `/swagger/v1/swagger.json`. Этот reference — human-readable summary.') out.append('') for ctrl in sorted(by_ctrl): items = sorted(by_ctrl[ctrl], key=lambda x: (x[2], x[3])) base = items[0][1] out.append(f'## `{ctrl}`') if base: out.append(f'Base route: `/{base.strip("/")}`') out.append('') out.append('| Method | Route | Permission | Summary |') out.append('|---|---|---|---|') for _, _, meth, route, perm, sum_ in items: perm_str = f'`{perm}`' if perm else '—' sum_ = (sum_[:100] + '…') if len(sum_) > 100 else sum_ sum_ = sum_.replace('|', '\\|') out.append(f'| {meth} | `{route}` | {perm_str} | {sum_} |') out.append('') OUT.write_text('\n'.join(out), encoding='utf-8') print(f'wrote {OUT} with {len(endpoints)} endpoints, {len(by_ctrl)} controllers') return 0 if __name__ == '__main__': sys.exit(main())