mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
1
changelog.d/maintenance/10875-combos-id-verb-coverage.md
Normal file
1
changelog.d/maintenance/10875-combos-id-verb-coverage.md
Normal 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))
|
||||
@@ -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
|
||||
|
||||
@@ -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` : ""}`
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user