trilium/apps/server/spec/etapi/openapi-spec.spec.ts
Elian Doran 09dba77c0a
docs(etapi): document the metrics endpoint and correct its default format
GET /etapi/metrics was served but absent from etapi.openapi.yaml, so the
only description of it lived in the User Guide. It is now specified,
including a Metrics schema mirroring the MetricsData shape that
collectMetrics() returns, and the route drops out of the spec coverage
test's exclusion list — only the route serving the specification itself
stays excluded.

The internal specification advertised format=json as the default for the
sibling GET /api/metrics, but getMetrics() falls back to prometheus. The
JSDoc @swagger block above that handler already said prometheus, so the
two descriptions of one endpoint disagreed and the consumed one was wrong.

Note the @swagger block is not wired to anything: build-docs renders
internal.openapi.yaml and etapi.openapi.yaml through redocly, and
swagger-jsdoc is not a dependency. Left in place, since deciding its fate
is separate from correcting the specification it contradicted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:53:30 +03:00

94 lines
3.7 KiB
TypeScript

import { Application, Router } from "express";
import { load } from "js-yaml";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import config from "../../src/services/config.js";
import etapiAppInfoRoutes from "../../src/etapi/app_info.js";
import etapiAttachmentRoutes from "../../src/etapi/attachments.js";
import etapiAttributeRoutes from "../../src/etapi/attributes.js";
import etapiAuthRoutes from "../../src/etapi/auth.js";
import etapiBackupRoute from "../../src/etapi/backup.js";
import etapiBranchRoutes from "../../src/etapi/branches.js";
import etapiMetricsRoute from "../../src/etapi/metrics.js";
import etapiNoteRoutes from "../../src/etapi/notes.js";
import etapiRevisionsRoutes from "../../src/etapi/revisions.js";
import etapiSpecialNoteRoutes from "../../src/etapi/special_notes.js";
let app: Application;
/** The specification cannot usefully describe the route serving it. Everything else must be documented. */
const UNDOCUMENTED_ROUTES = new Set(["/etapi.openapi.yaml"]);
describe("etapi/etapi.openapi.yaml", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await import("../../src/app.js")).default;
app = await buildApp();
});
it("serves the OpenAPI specification (and the cached copy on repeat)", async () => {
const response = await supertest(app).get("/etapi/etapi.openapi.yaml").expect(200);
expect(response.headers["content-type"]).toContain("text/plain");
expect(response.text).toContain("openapi");
// Second request is served from the in-memory cache.
const cached = await supertest(app).get("/etapi/etapi.openapi.yaml").expect(200);
expect(cached.text).toStrictEqual(response.text);
});
it("documents every route registered under /etapi", async () => {
const response = await supertest(app).get("/etapi/etapi.openapi.yaml").expect(200);
const spec = load(response.text) as { paths: Record<string, Record<string, unknown>> };
const undocumented = collectEtapiRoutes().filter(({ method, path }) => {
if (UNDOCUMENTED_ROUTES.has(path)) {
return false;
}
return !spec.paths[path]?.[method];
});
expect(undocumented).toStrictEqual([]);
});
});
/**
* Registers the ETAPI route modules onto a bare router and reads back what they mounted, as
* `{ method, path }` pairs addressed the way the specification addresses them: without the
* /etapi prefix and with `:date` rewritten to `{date}`.
*/
function collectEtapiRoutes() {
const router = Router();
etapiAuthRoutes.register(router, []);
etapiAppInfoRoutes.register(router);
etapiAttachmentRoutes.register(router);
etapiAttributeRoutes.register(router);
etapiBackupRoute.register(router);
etapiBranchRoutes.register(router);
etapiMetricsRoute.register(router);
etapiNoteRoutes.register(router);
etapiRevisionsRoutes.register(router);
etapiSpecialNoteRoutes.register(router);
const routes: { method: string; path: string }[] = [];
for (const layer of router.stack) {
// Express exposes the verbs a route was mounted with, but its types don't declare them.
const route = layer.route as (typeof layer.route & { methods: Record<string, boolean> }) | undefined;
if (!route?.path.startsWith("/etapi/")) {
continue;
}
const path = route.path.replace("/etapi", "").replace(/:(\w+)/g, "{$1}");
for (const [method, enabled] of Object.entries(route.methods)) {
if (enabled && method !== "_all") {
routes.push({ method, path });
}
}
}
expect(routes.length).toBeGreaterThan(0);
return routes;
}