Compare commits

...
Author SHA1 Message Date
gorkem-bwl faedb5ba4b feat(server): generate /monitors openapi spec from Zod (spike)
The hand-maintained openapi.json has drifted significantly from the
actual API surface (PUT vs PATCH mismatches, missing query params,
stale endpoints, missing routes). This is a spike of the long-term
fix: derive the spec from the Zod validation schemas we already have.

Scope: monitors resource only. Other resources still come from the
static openapi.json — the new merger splices generated paths in and
drops the static ones the registry now owns. Migration can proceed
one resource per PR.

What this PR does:

- Adds @asteasolutions/zod-to-openapi (peerDep zod 4 satisfied).
- New src/openapi/ module:
  - registry.ts — shared OpenAPIRegistry, bearerAuth scheme.
  - schemas/common.ts — success/error envelope helpers.
  - schemas/monitor.ts — Monitor / MonitorWithChecksList / etc.
  - routes/monitor.ts — registers all 19 monitor routes against
    the existing Zod validation schemas (no validation files modified).
  - index.ts — getOpenApiSpec() merges generated monitor paths into
    the static openapi.json and drops 2 stale entries.
- src/index.ts now calls getOpenApiSpec() instead of reading
  openapi.json directly. The /api-docs Swagger UI is unchanged.

Drift fixes that fall out automatically once monitors are derived:

- PUT /monitors/{monitorId} -> PATCH (matches actual route)
- POST /monitors response 201 -> 200 (matches actual handler)
- GET /monitors/team/with-checks: 1 documented param -> 8 actual
  (this is what #3354 fixed manually; now structural)
- /monitors/bulk and /monitors/test-email removed (no such routes)
- PATCH /monitors/notifications, POST /monitors/import/json, and
  GET /monitors/{id}/geo-checks are now documented (were missing)

Verified by booting the merged spec in isolation: 16 monitor paths
emitted, all stale entries dropped, Monitor schema and bearerAuth
scheme registered.

Follow-ups (separate PRs):
- Migrate auth, statusPage, maintenanceWindow, notification, etc.
- Once all resources are migrated, delete the hand-edited
  openapi.json and ship the generator output as the canonical doc.
- Optionally promote response shapes from passthrough() Monitor
  to fully typed response Zod schemas.
2026-04-24 19:58:56 -04:00
8 changed files with 490 additions and 18 deletions

No files matched your search

+24 -16
View File
@@ -46,6 +46,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@eslint/js": "^9.17.0",
"@types/compression": "1.8.1",
"@types/cookie-parser": "1.4.10",
@@ -126,6 +127,19 @@
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"license": "MIT"
},
"node_modules/@asteasolutions/zod-to-openapi": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz",
"integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"openapi3-ts": "^4.1.2"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
@@ -851,7 +865,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1471,7 +1484,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1516,7 +1528,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4230,7 +4241,6 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -4391,7 +4401,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -4629,7 +4638,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -5243,7 +5251,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5822,7 +5829,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6716,7 +6722,6 @@
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssnano-preset-default": "^7.0.9",
"lilconfig": "^3.1.3"
@@ -7481,7 +7486,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7843,7 +7847,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -9512,7 +9515,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -11956,6 +11958,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi3-ts": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz",
"integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yaml": "^2.8.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -12347,7 +12359,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -14370,7 +14381,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14539,7 +14549,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -14703,7 +14712,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+1
View File
@@ -64,6 +64,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@eslint/js": "^9.17.0",
"@types/compression": "1.8.1",
"@types/cookie-parser": "1.4.10",
+2 -2
View File
@@ -3,9 +3,9 @@ import { initializeControllers } from "./config/controllers.js";
import { createApp } from "./app.js";
import { initShutdownListener } from "@/shutdown.js";
import { validateEnv } from "@/validation/envValidation.js";
import { getOpenApiSpec } from "@/openapi/index.js";
import { fileURLToPath } from "url";
import path from "path";
import fs from "fs";
import Logger, { ILogger } from "@/utils/logger.js";
import { SettingsService } from "@/service/index.js";
@@ -20,7 +20,7 @@ const startApp = async () => {
// FE path
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const openApiSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../openapi.json"), "utf8"));
const openApiSpec = getOpenApiSpec();
const frontendPath = path.join(__dirname, "..", "public");
// Create settings service (env only — DB repository injected after connect)
+52
View File
@@ -0,0 +1,52 @@
import "./registry.js";
import "./routes/monitor.js";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import type { JsonObject } from "swagger-ui-express";
import { registry } from "./registry.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let cached: JsonObject | null = null;
export function getOpenApiSpec(): JsonObject {
if (cached) return cached;
const staticSpec = JSON.parse(fs.readFileSync(path.join(__dirname, "../../openapi.json"), "utf8")) as Record<string, unknown>;
const generator = new OpenApiGeneratorV3(registry.definitions);
const generated = generator.generateDocument({
openapi: "3.0.0",
info: { title: "generated", version: "0.0.0" },
});
const generatedPaths = (generated.paths ?? {}) as Record<string, unknown>;
const generatedSchemas = (generated.components?.schemas ?? {}) as Record<string, unknown>;
const generatedSecuritySchemes = (generated.components?.securitySchemes ?? {}) as Record<string, unknown>;
const staticPaths = (staticSpec.paths ?? {}) as Record<string, unknown>;
const staticComponents = (staticSpec.components ?? {}) as Record<string, unknown>;
const staticSchemas = (staticComponents.schemas ?? {}) as Record<string, unknown>;
const staticSecuritySchemes = (staticComponents.securitySchemes ?? {}) as Record<string, unknown>;
// Drop static paths that the registry now owns, plus stale entries
// for routes that no longer exist in the codebase.
const owned = new Set(Object.keys(generatedPaths));
const stale = new Set(["/monitors/bulk", "/monitors/test-email"]);
const filteredStaticPaths = Object.fromEntries(Object.entries(staticPaths).filter(([key]) => !owned.has(key) && !stale.has(key)));
cached = {
...staticSpec,
paths: { ...filteredStaticPaths, ...generatedPaths },
components: {
...staticComponents,
schemas: { ...staticSchemas, ...generatedSchemas },
securitySchemes: { ...staticSecuritySchemes, ...generatedSecuritySchemes },
},
};
return cached;
}
+20
View File
@@ -0,0 +1,20 @@
import { OpenAPIRegistry, OpenApiGeneratorV3, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
export const registry = new OpenAPIRegistry();
registry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
});
export function generateMonitorPaths() {
const generator = new OpenApiGeneratorV3(registry.definitions);
return generator.generateDocument({
openapi: "3.0.0",
info: { title: "Checkmate generated paths", version: "0.0.0" },
});
}
+301
View File
@@ -0,0 +1,301 @@
import "../registry.js";
import { z } from "zod";
import { registry } from "../registry.js";
import { successEnvelope, successEnvelopeNoData, errorEnvelope } from "../schemas/common.js";
import {
MonitorSchema,
MonitorWithChecksListSchema,
CertificateInfoSchema,
NotificationsUpdateResultSchema,
ImportResultSchema,
} from "../schemas/monitor.js";
import {
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorsWithChecksQueryValidation,
getCertificateParamValidation,
createMonitorBodyValidation,
editMonitorBodyValidation,
pauseMonitorParamValidation,
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
} from "@/validation/monitorValidation.js";
import { updateNotificationsValidation } from "@/validation/notificationValidation.js";
const bearer = [{ bearerAuth: [] }];
const tags = ["monitors"];
const json = <T extends z.ZodTypeAny>(schema: T) => ({
"application/json": { schema },
});
const standardErrors = {
"401": { description: "Unauthorized", content: json(errorEnvelope) },
"403": { description: "Forbidden", content: json(errorEnvelope) },
"500": { description: "Internal server error", content: json(errorEnvelope) },
};
registry.registerPath({
method: "get",
path: "/monitors/team",
tags,
summary: "List monitors for the caller's team",
security: bearer,
request: { query: getMonitorsByTeamIdQueryValidation },
responses: {
"200": { description: "OK", content: json(successEnvelope(z.array(MonitorSchema))) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/team/with-checks",
tags,
summary: "List team monitors with their most recent checks (paginated)",
security: bearer,
request: { query: getMonitorsWithChecksQueryValidation },
responses: {
"200": { description: "OK", content: json(successEnvelope(MonitorWithChecksListSchema)) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/team/groups",
tags,
summary: "List monitor groups for the caller's team",
security: bearer,
responses: {
"200": { description: "OK", content: json(successEnvelope(z.array(z.string()))) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/uptime/details/{monitorId}",
tags,
summary: "Get uptime details for a monitor",
security: bearer,
request: {
params: getUptimeDetailsByIdParamValidation,
query: getUptimeDetailsByIdQueryValidation,
},
responses: {
"200": { description: "OK", content: json(successEnvelope(z.unknown())) },
"400": { description: "Validation error", content: json(errorEnvelope) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/hardware/details/{monitorId}",
tags,
summary: "Get hardware metrics detail for a monitor",
security: bearer,
request: {
params: getHardwareDetailsByIdParamValidation,
query: getHardwareDetailsByIdQueryValidation,
},
responses: {
"200": { description: "OK", content: json(successEnvelope(z.unknown())) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/pagespeed/details/{monitorId}",
tags,
summary: "Get PageSpeed detail for a monitor",
security: bearer,
request: {
params: getHardwareDetailsByIdParamValidation,
query: getHardwareDetailsByIdQueryValidation,
},
responses: {
"200": { description: "OK", content: json(successEnvelopeNoData) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/{monitorId}/geo-checks",
tags,
summary: "Get geo check results for a monitor",
security: bearer,
request: {
params: getMonitorByIdParamValidation,
query: getMonitorByIdQueryValidation,
},
responses: {
"200": { description: "OK", content: json(successEnvelopeNoData) },
...standardErrors,
},
});
registry.registerPath({
method: "post",
path: "/monitors/pause/{monitorId}",
tags,
summary: "Toggle pause state for a monitor",
security: bearer,
request: { params: pauseMonitorParamValidation },
responses: {
"200": { description: "OK", content: json(successEnvelope(MonitorSchema)) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/certificate/{monitorId}",
tags,
summary: "Get SSL certificate info for a monitor",
security: bearer,
request: { params: getCertificateParamValidation },
responses: {
"200": { description: "OK", content: json(successEnvelope(CertificateInfoSchema)) },
...standardErrors,
},
});
registry.registerPath({
method: "patch",
path: "/monitors/notifications",
tags,
summary: "Bulk update notifications across monitors",
security: bearer,
request: { body: { content: json(updateNotificationsValidation) } },
responses: {
"200": { description: "OK", content: json(successEnvelope(NotificationsUpdateResultSchema)) },
...standardErrors,
},
});
registry.registerPath({
method: "post",
path: "/monitors",
tags,
summary: "Create a new monitor",
security: bearer,
request: { body: { content: json(createMonitorBodyValidation) } },
responses: {
"200": { description: "Monitor created", content: json(successEnvelope(MonitorSchema)) },
...standardErrors,
},
});
registry.registerPath({
method: "delete",
path: "/monitors",
tags,
summary: "Delete every monitor (superadmin only)",
security: bearer,
responses: {
"200": { description: "OK", content: json(successEnvelopeNoData) },
...standardErrors,
},
});
registry.registerPath({
method: "post",
path: "/monitors/demo",
tags,
summary: "Insert preconfigured demo monitors",
security: bearer,
responses: {
"200": { description: "OK", content: json(successEnvelope(z.number())) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/export/json",
tags,
summary: "Export all team monitors as JSON",
security: bearer,
responses: {
"200": { description: "OK", content: json(successEnvelope(z.unknown())) },
...standardErrors,
},
});
registry.registerPath({
method: "post",
path: "/monitors/import/json",
tags,
summary: "Import monitors from JSON",
security: bearer,
request: { body: { content: json(importMonitorsBodyValidation) } },
responses: {
"200": { description: "OK", content: json(successEnvelope(ImportResultSchema)) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/games",
tags,
summary: "List supported game-server types",
security: bearer,
responses: {
"200": { description: "OK", content: json(successEnvelope(z.array(z.string()))) },
...standardErrors,
},
});
registry.registerPath({
method: "get",
path: "/monitors/{monitorId}",
tags,
summary: "Get a monitor by id",
security: bearer,
request: { params: getMonitorByIdParamValidation },
responses: {
"200": { description: "OK", content: json(successEnvelope(MonitorSchema)) },
"404": { description: "Monitor not found", content: json(errorEnvelope) },
...standardErrors,
},
});
registry.registerPath({
method: "patch",
path: "/monitors/{monitorId}",
tags,
summary: "Edit a monitor",
security: bearer,
request: {
params: getMonitorByIdParamValidation,
body: { content: json(editMonitorBodyValidation) },
},
responses: {
"200": { description: "OK", content: json(successEnvelope(MonitorSchema)) },
"404": { description: "Monitor not found", content: json(errorEnvelope) },
...standardErrors,
},
});
registry.registerPath({
method: "delete",
path: "/monitors/{monitorId}",
tags,
summary: "Delete a monitor",
security: bearer,
request: { params: getMonitorByIdParamValidation },
responses: {
"200": { description: "OK", content: json(successEnvelope(MonitorSchema)) },
"404": { description: "Monitor not found", content: json(errorEnvelope) },
...standardErrors,
},
});
+19
View File
@@ -0,0 +1,19 @@
import { z } from "zod";
export const successEnvelope = <T extends z.ZodTypeAny>(data: T) =>
z.object({
success: z.literal(true),
msg: z.string(),
data,
});
export const successEnvelopeNoData = z.object({
success: z.literal(true),
msg: z.string(),
});
export const errorEnvelope = z.object({
success: z.literal(false),
msg: z.string(),
data: z.unknown().optional(),
});
+71
View File
@@ -0,0 +1,71 @@
import "../registry.js";
import { z } from "zod";
import { GeoContinents } from "@/types/geoCheck.js";
import { MonitorMatchMethods, MonitorTypes } from "@/types/monitor.js";
export const MonitorSchema = z
.object({
_id: z.string().openapi({ example: "65f1c2a4d8b9e0123456789a" }),
name: z.string(),
description: z.string().optional(),
type: z.enum(MonitorTypes),
url: z.string(),
port: z.number().optional(),
isActive: z.boolean(),
interval: z.number(),
status: z.enum(["up", "down", "paused", "initializing", "maintenance", "breached"]),
statusWindowSize: z.number(),
statusWindowThreshold: z.number(),
ignoreTlsErrors: z.boolean(),
useAdvancedMatching: z.boolean(),
jsonPath: z.string().optional(),
expectedValue: z.string().optional(),
matchMethod: z.enum(MonitorMatchMethods).optional(),
notifications: z.array(z.string()),
secret: z.string().optional(),
cpuAlertThreshold: z.number(),
memoryAlertThreshold: z.number(),
diskAlertThreshold: z.number(),
tempAlertThreshold: z.number(),
selectedDisks: z.array(z.string()),
gameId: z.string().optional(),
grpcServiceName: z.string().optional(),
group: z.string().nullable().optional(),
geoCheckEnabled: z.boolean(),
geoCheckLocations: z.array(z.enum(GeoContinents)),
geoCheckInterval: z.number(),
teamId: z.string(),
userId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
.passthrough()
.openapi("Monitor");
export const MonitorWithChecksListSchema = z
.object({
monitors: z.array(MonitorSchema),
monitorCount: z.number(),
filteredCount: z.number(),
})
.passthrough()
.openapi("MonitorWithChecksList");
export const CertificateInfoSchema = z
.object({
certificateDate: z.string(),
})
.openapi("CertificateInfo");
export const NotificationsUpdateResultSchema = z
.object({
modifiedCount: z.number(),
})
.openapi("NotificationsUpdateResult");
export const ImportResultSchema = z
.object({
imported: z.number(),
})
.passthrough()
.openapi("ImportResult");