fix(swagger): operationId + schemaId — генерация OpenAPI работает
Some checks are pending
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

В Development swagger.json валился двумя ошибками:

1. CustomOperationIds dereferencing api.ActionDescriptor.RouteValues['action']
   для минимальных API (/health, /metrics, /connect/*) кидало
   KeyNotFoundException. Делаем TryGetValue + fallback на RelativePath.

2. CustomSchemaIds с FullName! падал NRE на типах без FullName
   (generic-параметры). Fallback на t.Name через ??.

После фикса: /swagger/v1/swagger.json 200, 117 paths, все 19 новых
модулей (Enter/Loss/Transfer/Inventory/SupplierReturn/Demand/Reports/
AuditLog/2FA/POS/Signup) присутствуют, schemaId без дубликатов.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-29 17:51:23 +05:00
parent 6b6f27d238
commit 466595b4d5
5 changed files with 140 additions and 5 deletions

View file

@ -29,7 +29,7 @@
- [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. *(stage-demand.yml: 8/8 ✓)*
- [x] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge. *(stage-reports.yml: 8/8 ✓, 3 фикса: UTC dates, Enter→Cost, ABC Pareto)*
- [x] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго. *(stage-audit-log.yml: 7/7 ✓)*
- [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400.
- [x] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400. *(stage-2fa.yml: 6/6 ✓)*
- [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json.
- [ ] **14. POS Sync API**`POST /api/pos/sync` (dev-token), `POST /api/pos/sales` (idempotency-key — повтор не дублирует).

View file

@ -229,18 +229,25 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// <Controller>_<Verb><Action>. Verb включён чтобы избежать коллизии
// когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync)
// получают одинаковое имя action → одинаковый operationId → duplicate.
// Минимальные API (/health, /metrics, /connect/*) не имеют ключей
// controller/action в RouteValues — для них фоллбэк на RelativePath.
opts.CustomOperationIds(api =>
{
var ctrl = api.ActionDescriptor.RouteValues["controller"];
var action = api.ActionDescriptor.RouteValues["action"];
var rv = api.ActionDescriptor.RouteValues;
rv.TryGetValue("controller", out var ctrl);
rv.TryGetValue("action", out var action);
var verb = api.HttpMethod is { Length: > 0 } m ? char.ToUpper(m[0]) + m[1..].ToLowerInvariant() : "";
if (string.IsNullOrEmpty(ctrl) || string.IsNullOrEmpty(action))
return $"{verb}_{api.RelativePath?.Replace('/', '_').Replace('{', '_').Replace('}', '_')}";
return $"{ctrl}_{verb}{action}";
});
// У нас есть одноимённые nested record'ы в разных контроллерах
// (например, StockRow в StockController и StockReportController).
// (например, StockRow в StockController и StockReportController, или
// EmployeeInput в Organizations.EmployeesController и SuperAdmin).
// Включаем имя контроллера в schemaId через FullName-suffix чтобы не
// словить duplicate schemaId — Swashbuckle падает на этом по умолчанию.
opts.CustomSchemaIds(t => t.FullName!
// FullName может быть null для generic-параметров — fallback на Name.
opts.CustomSchemaIds(t => (t.FullName ?? t.Name)
.Replace("foodmarket.Api.Controllers.", "")
.Replace("foodmarket.Application.", "")
.Replace("foodmarket.Domain.", "")

View file

@ -0,0 +1,42 @@
# E2E report: stage-swagger
Запущен: 2026-05-29T12:51:11.743Z
Длительность: 0.3с
**Итог:** 3 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 3)
## ✓ Step sw01_swagger_endpoint: GET /swagger/v1/swagger.json → 200 (Development), документ валидный JSON
Длительность: 322мс
| Тип | Проверка | Результат |
|---|---|---|
| api | GET /swagger/v1/swagger.json → 200 | ✓ 200 |
| api | paths.length > 50 | ✓ paths=117 |
## ✓ Step sw02_all_new_modules_present: Все новые модули (Enter/Loss/Transfer/Inventory/SupplierReturn/Demand/Reports/AuditLog/2FA/POS) в paths
Длительность: 1мс
| Тип | Проверка | Результат |
|---|---|---|
| api | Все 19 новых модулей присутствуют | ✓ все ок |
## ✓ Step sw03_schema_ids_unique: Все schemaId уникальны (нет коллизий типа EmployeeInput)
Длительность: 1мс
| Тип | Проверка | Результат |
|---|---|---|
| api | schemaId дубликатов нет (всего 172) | ✓ unique |
## Summary
- Passed: 3
- Failed: 0
- Warnings: 0
- Skipped: 0
## Critical bugs
Нет.

View file

@ -0,0 +1,68 @@
/**
* Stage Swagger: проверяет что OpenAPI генерируется и все новые модули
* присутствуют. Stage работает в Production, поэтому Swagger там
* выключен этот сценарий гоняется ЛОКАЛЬНО (E2E_ADMIN_URL=http://localhost:5081).
* Если эндпоинт недоступен, шаги помечаются skip.
*/
import { makeClient } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
type Ctx = {
apiOnly: boolean
swagger?: { paths: Record<string, unknown>; components?: { schemas?: Record<string, unknown> } }
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
function check(step: Step, c: CheckResult) { step.checks.push(c) }
export async function sw01_swagger_endpoint({ ctx, step, report }: StepCtx) {
const api = makeClient()
const r = await api.get('/swagger/v1/swagger.json')
if (r.status === 404) {
step.status = 'skip'
step.notes.push('Swagger не доступен на этом окружении (видимо Production). Запусти локально с E2E_ADMIN_URL=http://localhost:5081.')
return
}
check(step, { kind: 'api', description: 'GET /swagger/v1/swagger.json → 200', ok: r.status === 200, detail: `${r.status}` })
check(step, { kind: 'api', description: 'paths.length > 50', ok: typeof r.data === 'object' && r.data && Object.keys(r.data.paths ?? {}).length > 50, detail: `paths=${Object.keys(r.data?.paths ?? {}).length}` })
ctx.swagger = r.data
}
export async function sw02_all_new_modules_present({ ctx, step, report }: StepCtx) {
if (!ctx.swagger) { step.status = 'skip'; return }
const paths = Object.keys(ctx.swagger.paths ?? {})
const expected = [
'/api/inventory/enters', '/api/inventory/losses', '/api/inventory/transfers',
'/api/inventory/inventories', '/api/inventory/stock',
'/api/purchases/supplier-returns', '/api/purchases/supplies',
'/api/sales/demands', '/api/sales/retail',
'/api/reports/sales', '/api/reports/stock', '/api/reports/profit', '/api/reports/abc',
'/api/admin/audit-log', '/api/me/2fa/enroll', '/api/me/2fa/verify',
'/api/me/2fa/disable', '/api/pos/v1', '/api/auth/signup',
]
const missing = expected.filter(e => !paths.some(p => p.startsWith(e)))
check(step, {
kind: 'api', description: `Все ${expected.length} новых модулей присутствуют`,
ok: missing.length === 0, detail: missing.length ? `missing: ${missing.join(', ')}` : 'все ок',
})
}
export async function sw03_schema_ids_unique({ ctx, step, report }: StepCtx) {
if (!ctx.swagger) { step.status = 'skip'; return }
const schemas = ctx.swagger.components?.schemas ?? {}
const names = Object.keys(schemas)
// Уже проверка факта генерации (если был бы collision, swagger.json не сгенерился).
// Дополнительно — нет дублей по lowercase (защита от case-insensitive дубля).
const lower = new Map<string, string[]>()
for (const n of names) {
const k = n.toLowerCase()
const arr = lower.get(k) ?? []
arr.push(n)
lower.set(k, arr)
}
const dups = [...lower.entries()].filter(([, v]) => v.length > 1)
check(step, {
kind: 'api', description: `schemaId дубликатов нет (всего ${names.length})`,
ok: dups.length === 0, detail: dups.length ? `dups: ${dups.map(([k]) => k).slice(0, 5).join(', ')}` : 'unique',
})
}

View file

@ -0,0 +1,18 @@
name: stage-swagger
description: |
OpenAPI/Swagger: stage работает в Production где /swagger не
раскрывается (sensitive endpoint enumeration). Тестируем локально
через E2E_ADMIN_URL=http://localhost:5081, проверяем что все новые
контроллеры зарегистрированы.
preconditions:
reset_db: false
smoke_login_super_admin: false
steps:
- id: sw01_swagger_endpoint
title: GET /swagger/v1/swagger.json → 200 (Development), документ валидный JSON
- id: sw02_all_new_modules_present
title: Все новые модули (Enter/Loss/Transfer/Inventory/SupplierReturn/Demand/Reports/AuditLog/2FA/POS) в paths
- id: sw03_schema_ids_unique
title: Все schemaId уникальны (нет коллизий типа EmployeeInput)