food-market/scripts/gen-api-reference.py
nns 99b84132ba
Some checks failed
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled
fix(s28): api-reference handle ~/path ASP.NET convention
ASP.NET Core convention для HttpX-атрибутов: `~/path` означает
'absolute from root, ignore class [Route]'. До фикса генератор клеил
`base-route` + `~/path` → невалидный `/~/connect/token`.

Теперь tilde корректно срезается, /connect/token виден в reference.

Также добавлен unit test ApiReferenceDocsJobTests (Sprint 28) для
lock-down regex behavior на double-nested generics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 03:41:15 +05:00

181 lines
7.1 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Sprint 28: api-reference.md generator (улучшенная версия).
Sprint 24's `/tmp/gen-api-ref.py` пропускал endpoint'ы с nested generic
return-типами (например `Task<ActionResult<PagedResult<EmployeeDto>>>`),
поэтому в `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(?P<verb>Get|Post|Put|Delete|Patch)(?:\("(?P<sub>[^"]*)"\))?(?:[^\]]*)?\]'
r'(?P<other>(?:[ \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'<summary>\s*(.*?)\s*</summary>', 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 /// <summary> 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())