fix: add openapi docs and error handling to host tag routes

This commit is contained in:
LukeGus committed 2026-10-03 15:45:41 -05:00
1 parent a1b37c6829
commit 7156ac1af3
2 files changed
+121 -28

No files matched your search

+110 -28
View File
@@ -1,40 +1,122 @@
import type { RequestHandler, Router } from "express"; import type { Request, RequestHandler, Response, Router } from "express";
import { createCurrentSettingsRepository } from "../repositories/factory.js"; import { createCurrentSettingsRepository } from "../repositories/factory.js";
import { databaseLogger } from "../../utils/logger.js";
const CATALOG_KEY = "host_tag_catalog"; const CATALOG_KEY = "host_tag_catalog";
function parseCatalog(value: string | null | undefined): string[] {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((tag): tag is string => typeof tag === "string")
: [];
} catch {
return [];
}
}
export function registerHostTagRoutes( export function registerHostTagRoutes(
router: Router, router: Router,
authenticate: RequestHandler, authenticate: RequestHandler,
requireAdmin: RequestHandler, requireAdmin: RequestHandler,
): void { ): void {
router.get("/tags", authenticate, async (_req, res) => { /**
const value = await createCurrentSettingsRepository().get(CATALOG_KEY); * @openapi
res.json({ tags: value ? JSON.parse(value) : [] }); * /host/tags:
* get:
* summary: Get the predefined host tags
* description: The instance-wide tag list host editors suggest. Any signed-in user can read it.
* tags:
* - SSH
* responses:
* 200:
* description: The predefined tags.
* content:
* application/json:
* schema:
* type: object
* properties:
* tags:
* type: array
* items:
* type: string
* 500:
* description: Failed to read the tags.
*/
router.get("/tags", authenticate, async (_req: Request, res: Response) => {
try {
const value = await createCurrentSettingsRepository().get(CATALOG_KEY);
res.json({ tags: parseCatalog(value) });
} catch (error) {
databaseLogger.error("Failed to read host tags", error);
res.status(500).json({ error: "Failed to read host tags" });
}
}); });
router.put("/tags", authenticate, requireAdmin, async (req, res) => { /**
const tags: unknown = req.body?.tags; * @openapi
if ( * /host/tags:
!Array.isArray(tags) || * put:
tags.length > 500 || * summary: Replace the predefined host tags
tags.some( * description: Sets the instance-wide tag list. Tags are trimmed and duplicates dropped. Tags already on hosts are not changed. Requires admin.settings.manage.
(tag) => * tags:
typeof tag !== "string" || !tag.trim() || tag.trim().length > 100, * - SSH
) * requestBody:
) { * required: true
res.status(400).json({ * content:
error: "Expected up to 500 non-empty tags of at most 100 characters", * application/json:
}); * schema:
return; * type: object
} * properties:
const normalized = [ * tags:
...new Set((tags as string[]).map((tag) => tag.trim())), * type: array
]; * maxItems: 500
await createCurrentSettingsRepository().set( * items:
CATALOG_KEY, * type: string
JSON.stringify(normalized), * maxLength: 100
); * responses:
res.json({ tags: normalized }); * 200:
}); * description: The saved tags.
* 400:
* description: Not an array of up to 500 non-empty tags of at most 100 characters.
* 403:
* description: Missing admin.settings.manage.
* 500:
* description: Failed to save the tags.
*/
router.put(
"/tags",
authenticate,
requireAdmin,
async (req: Request, res: Response) => {
const tags: unknown = req.body?.tags;
if (
!Array.isArray(tags) ||
tags.length > 500 ||
tags.some(
(tag) =>
typeof tag !== "string" || !tag.trim() || tag.trim().length > 100,
)
) {
res.status(400).json({
error: "Expected up to 500 non-empty tags of at most 100 characters",
});
return;
}
const normalized = [
...new Set((tags as string[]).map((tag) => tag.trim())),
];
try {
await createCurrentSettingsRepository().set(
CATALOG_KEY,
JSON.stringify(normalized),
);
res.json({ tags: normalized });
} catch (error) {
databaseLogger.error("Failed to save host tags", error);
res.status(500).json({ error: "Failed to save host tags" });
}
},
);
} }
@@ -11,6 +11,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}, },
}), }),
})); }));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn() },
}));
import { registerHostTagRoutes } from "../../../database/routes/host-tag-routes.js"; import { registerHostTagRoutes } from "../../../database/routes/host-tag-routes.js";
function app() { function app() {
@@ -68,4 +71,12 @@ describe("instance host tag catalog", () => {
.send({ tags: [] }) .send({ tags: [] })
.expect(200, { tags: [] }); .expect(200, { tags: [] });
}); });
it("reads a damaged catalog as empty instead of failing", async () => {
settings.set("host_tag_catalog", "{not json");
await request(app())
.get("/tags")
.set("Authorization", "member")
.expect(200, { tags: [] });
});
}); });