docs(openapi): document GET and PUT on /api/combos/[id] (#10875)

Obrigado — TDD exemplar num gap real de contrato: as duas operações que o dashboard realmente chama em /api/combos/{id} (GET e PUT) estavam ausentes do openapi.yaml, enquanto a única operação documentada (patch, antes deste #10869) não tinha handler. Adiciona um floor de cobertura por OPERAÇÃO (não só por PATH) que o gate existente não capturava, medido em 343/985 (34.8%).

Validação (worktree combinado a partir de origin/release/v3.8.50, 0 conflitos):
- typecheck:core limpo, complexity/cognitive-complexity dentro do baseline
- tests/unit/openapi-coverage.test.ts — passando com o novo floor de operações
- openapi-routes/openapi-coverage/openapi-security-tiers gates — PASS
This commit is contained in:
Dizzle
2026-08-20 20:47:24 +02:00
committed by GitHub
parent dacf4c3c1a
commit 82ed31d27a
3 changed files with 106 additions and 5 deletions

View File

@@ -0,0 +1 @@
- **docs(openapi):** document the `GET` and `PUT` operations on `/api/combos/{id}`, and add an operation-level coverage floor so a missing verb can no longer hide behind a path that already counts as covered ([#10875](https://github.com/diegosouzapw/OmniRoute/pull/10875))

View File

@@ -2069,6 +2069,39 @@ paths:
description: Created combo
/api/combos/{id}:
get:
tags: [Combos]
summary: Get combo by ID
parameters:
- $ref: "#/components/parameters/ResourceId"
responses:
"200":
description: Combo details
"404":
description: Combo not found
put:
tags: [Combos]
summary: Update combo
description: >-
Partial update: the body is merged onto the stored combo, so a field left out keeps
its current value. An array that IS sent replaces the stored one outright.
parameters:
- $ref: "#/components/parameters/ResourceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
"200":
description: Updated combo
"400":
description: Invalid body, or the resulting combo fails validation
"404":
description: Combo not found
"409":
description: Name already taken, or the combo is quota-share managed
patch:
tags: [Combos]
summary: Update combo

View File

@@ -8,13 +8,13 @@ const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
function collectRoutePaths(dir: string): string[] {
function collectRouteFiles(dir: string): { apiPath: string; file: string }[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const paths: string[] = [];
const routes: { apiPath: string; file: string }[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
paths.push(...collectRoutePaths(fullPath));
routes.push(...collectRouteFiles(fullPath));
continue;
}
if (entry.isFile() && entry.name === "route.ts") {
@@ -22,10 +22,31 @@ function collectRoutePaths(dir: string): string[] {
.dirname(fullPath)
.replace(API_ROOT, "")
.replace(/\[([^\]]+)\]/g, "{$1}");
paths.push(`/api${apiPath}`);
routes.push({ apiPath: `/api${apiPath}`, file: fullPath });
}
}
return paths;
return routes;
}
function collectRoutePaths(dir: string): string[] {
return collectRouteFiles(dir).map((route) => route.apiPath);
}
// OPTIONS is deliberately absent: every v1 route exports it for CORS preflight, so it is
// transport boilerplate rather than API surface a consumer calls.
const DOCUMENTABLE_METHODS = ["get", "post", "put", "patch", "delete", "head"] as const;
/** The HTTP handlers a route.ts actually exports, across the export forms used in this repo. */
function exportedMethods(routeFile: string): string[] {
const source = fs.readFileSync(routeFile, "utf-8");
return DOCUMENTABLE_METHODS.filter((method) => {
const name = method.toUpperCase();
return (
new RegExp(`export\\s+(?:async\\s+)?function\\s+${name}\\b`).test(source) ||
new RegExp(`export\\s+(?:const|let|var)\\s+${name}\\b`).test(source) ||
new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`).test(source)
);
});
}
function normalizePath(p: string): string {
@@ -79,3 +100,49 @@ test("openapi.yaml does not regress documented-route coverage below the agreed f
`Missing: ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? ` ... +${missing.length - 10} more` : ""}`
);
});
// Floor recorded on 2026-08-20 for release/v3.8.50: 343/985 operations documented.
// The path floor above cannot see an operation: a route counts as covered the moment ONE
// of its verbs is documented. /api/combos/{id} exported GET, PUT and DELETE while the spec
// listed only `patch` and `delete` — a fully covered path hiding two operations, and the
// one `patch` it did document does not exist on that route. Schemathesis (dast-smoke.yml)
// only exercises documented operations, so the two hidden verbs never reached the fuzzer.
// Same "no regressions, not the absolute target" policy as the path floor: raising it is
// tracked as the same follow-up doc debt.
const OPENAPI_OPERATION_FLOOR_PERCENT = 34.8;
test("openapi.yaml does not regress documented-operation coverage below the agreed floor", () => {
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")) as {
paths?: Record<string, Record<string, unknown>>;
};
const documentedPaths = raw.paths ?? {};
let covered = 0;
const missing: string[] = [];
for (const { apiPath, file } of collectRouteFiles(API_ROOT)) {
const operations = documentedPaths[normalizePath(apiPath)];
for (const method of exportedMethods(file)) {
if (operations && operations[method]) {
covered++;
} else {
missing.push(`${method.toUpperCase()} ${apiPath}`);
}
}
}
const total = covered + missing.length;
const coverage = (covered / total) * 100;
if (coverage < OPENAPI_OPERATION_FLOOR_PERCENT) {
console.error(`Operation coverage: ${coverage.toFixed(1)}% (${covered}/${total})`);
console.error("Undocumented operations:");
missing.forEach((op) => console.error(` - ${op}`));
}
assert.ok(
coverage >= OPENAPI_OPERATION_FLOOR_PERCENT,
`OpenAPI operation coverage regressed: ${coverage.toFixed(1)}% < floor ${OPENAPI_OPERATION_FLOOR_PERCENT}%. ` +
`Undocumented: ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? ` ... +${missing.length - 10} more` : ""}`
);
});