food-market/scripts/gen-api-reference.py
nns ed140cb819
Some checks are pending
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) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
docs(s28): api-reference 195→240 + observability + integration #7 + CI
Overnight progress while 4h-soak runs in background:

1. ApiReferenceDocsJob.cs + scripts/gen-api-reference.py — return-type
   regex теперь ловит nested generics любой глубины. Было 195
   endpoint'ов в auto-gen reference; стало 240 (+45). EmployeesController
   GET /api/organization/employees был пропущен из-за
   Task<ActionResult<PagedResult<EmployeeDto>>>.

2. docs/observability.md — добавлен food_market_disk_free_bytes (Sprint 20)
   + раздел "quality-watchdog метрики" (5 метрик textfile exporter'a из
   Sprint 26: run_total, step_failure_total, endpoint_p95_ms,
   last_run_status, incidents_total). Готовые dashboards теперь содержат
   оба JSON (food-market.json + quality-watchdog.json).

3. tests/integration/07-import-export-flows.spec.ts — POST 1C-CSV import
   (semicolon-CSV cp1251) → создаются продукты с группой автоматом;
   POST /api/org/export (НЕ /api/admin/org-export) → возвращает
   {id, status}; orgB не видит export orgA. Прогон 8.2s.

4. tests/food-market.IntegrationTests/PruneQualityTestOrgsTests.cs —
   2 [Fact]'a для метода из Sprint 25. Удаляет только quality-* старше
   threshold, не трогает реальные org. Требует Testcontainers.

5. .forgejo/workflows/regression.yml — добавлен шаг integration suite
   после flows+visual. Telegram: "35 flows + 60 visual + 8 integration".

Soak-real (4h @ 50 RPS) запущен в setsid-detach session, продолжается.
Итоговые числа добавлю в sprint28-progress.md после завершения.

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

175 lines
6.7 KiB
Python
Executable file
Raw 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.
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())