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
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>
181 lines
7.1 KiB
Python
Executable file
181 lines
7.1 KiB
Python
Executable file
#!/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())
|