diff --git a/apps/server/src/routes/v1/monitors/index.ts b/apps/server/src/routes/v1/monitors/index.ts index 4cb2ff220..876a9d53f 100644 --- a/apps/server/src/routes/v1/monitors/index.ts +++ b/apps/server/src/routes/v1/monitors/index.ts @@ -6,6 +6,8 @@ import { registerDeleteMonitor } from "./delete"; import { registerGetMonitor } from "./get"; import { registerGetAllMonitors } from "./get_all"; import { registerPostMonitor } from "./post"; +import { registerPostMonitorHTTP } from "./post_http"; +import { registerPostMonitorTCP } from "./post_tcp"; import { registerPutMonitor } from "./put"; import { registerGetMonitorResult } from "./results/get"; import { registerRunMonitor } from "./run/post"; @@ -21,6 +23,8 @@ registerGetMonitor(monitorsApi); registerPutMonitor(monitorsApi); registerDeleteMonitor(monitorsApi); registerPostMonitor(monitorsApi); +registerPostMonitorHTTP(monitorsApi); +registerPostMonitorTCP(monitorsApi); // registerGetMonitorSummary(monitorsApi); registerTriggerMonitor(monitorsApi); diff --git a/apps/server/src/routes/v1/monitors/post_http.test.ts b/apps/server/src/routes/v1/monitors/post_http.test.ts new file mode 100644 index 000000000..aae470a27 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/post_http.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { MonitorSchema } from "./schema"; + +test("create a valid monitor", async () => { + const res = await app.request("/v1/monitor/http", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + frequency: "10m", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + request: { + url: "https://www.openstatus.dev", + method: "POST", + body: '{"hello":"world"}', + headers: { "content-type": "application/json" }, + }, + active: true, + public: true, + assertions: [ + { + type: "status", + compare: "eq", + target: 200, + }, + { type: "header", compare: "not_eq", key: "key", target: "value" }, + ], + }), + }); + + const result = MonitorSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("create a status report with invalid payload should return 400", async () => { + const res = await app.request("/v1/monitor/http", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + frequency: "21m", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + request: { + url: "https://www.openstatus.dev", + method: "POST", + body: '{"hello":"world"}', + headers: { "content-type": "application/json" }, + }, + active: true, + public: true, + assertions: [ + { + type: "status", + compare: "eq", + target: 200, + }, + { type: "header", compare: "not_eq", key: "key", target: "value" }, + ], + }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor/http", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/routes/v1/monitors/post_http.ts b/apps/server/src/routes/v1/monitors/post_http.ts new file mode 100644 index 000000000..4c597abb9 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/post_http.ts @@ -0,0 +1,123 @@ +import { createRoute, z } from "@hono/zod-openapi"; + +import { Events } from "@openstatus/analytics"; +import { and, db, eq, isNull, sql } from "@openstatus/db"; +import { monitor } from "@openstatus/db/src/schema"; + +import { serialize } from "@openstatus/assertions"; + +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; +import type { monitorsApi } from "./index"; +import { HTTPMonitorSchema, MonitorSchema } from "./schema"; +import { getAssertions } from "./utils"; + +const postRoute = createRoute({ + method: "post", + tags: ["monitor"], + summary: "Create a http monitor", + path: "/http", + middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], + request: { + body: { + description: "The monitor to create", + content: { + "application/json": { + schema: HTTPMonitorSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: MonitorSchema, + }, + }, + description: "Create a monitor", + }, + ...openApiErrorResponses, + }, +}); + +export function registerPostMonitorHTTP(api: typeof monitorsApi) { + return api.openapi(postRoute, async (c) => { + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; + const input = c.req.valid("json"); + const count = ( + await db + .select({ count: sql`count(*)` }) + .from(monitor) + .where( + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), + ) + .all() + )[0].count; + + if (count >= limits.monitors) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more monitors", + }); + } + + if (!limits.periodicity.includes(input.frequency)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more periodicity", + }); + } + + for (const region of input.regions) { + if (!limits.regions.includes(region)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more regions", + }); + } + } + + const { request, regions, assertions, otelHeaders, ...rest } = input; + + const headers = input.request.headers + ? Object.entries(input.request.headers) + : undefined; + + const otelHeadersEntries = otelHeaders + ? Object.entries(otelHeaders).map(([key, value]) => ({ + key: key, + value: value, + })) + : undefined; + const headersEntries = headers + ? headers.map(([key, value]) => ({ key: key, value: value })) + : undefined; + const assert = assertions ? getAssertions(assertions) : []; + + const _newMonitor = await db + .insert(monitor) + .values({ + ...rest, + periodicity: input.frequency, + url: request.url, + method: request.method, + body: request.body, + workspaceId: workspaceId, + regions: regions ? regions.join(",") : undefined, + headers: headersEntries ? JSON.stringify(headersEntries) : undefined, + assertions: assert.length > 0 ? serialize(assert) : undefined, + timeout: input.timeout || 45000, + otelHeaders: otelHeadersEntries + ? JSON.stringify(otelHeadersEntries) + : undefined, + }) + .returning() + .get(); + + const data = MonitorSchema.parse(_newMonitor); + + return c.json(data, 200); + }); +} diff --git a/apps/server/src/routes/v1/monitors/post_tcp.test.ts b/apps/server/src/routes/v1/monitors/post_tcp.test.ts new file mode 100644 index 000000000..a0e18335c --- /dev/null +++ b/apps/server/src/routes/v1/monitors/post_tcp.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; + +import { app } from "@/index"; +import { MonitorSchema } from "./schema"; + +test("create a valid monitor", async () => { + const res = await app.request("/v1/monitor/tcp", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + frequency: "10m", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + request: { + host: "openstatus.dev", + port: 443, + }, + active: true, + public: true, + assertions: [ + { + type: "status", + compare: "eq", + target: 200, + }, + { type: "header", compare: "not_eq", key: "key", target: "value" }, + ], + }), + }); + + const result = MonitorSchema.safeParse(await res.json()); + + expect(res.status).toBe(200); + expect(result.success).toBe(true); +}); + +test("create a status report with invalid payload should return 400", async () => { + const res = await app.request("/v1/monitor/tcp", { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + body: JSON.stringify({ + frequency: "21m", + name: "OpenStatus", + description: "OpenStatus website", + regions: ["ams", "gru"], + request: { + host: "openstatus.dev", + port: 443, + }, + active: true, + public: true, + }), + }); + + expect(res.status).toBe(400); +}); + +test("no auth key should return 401", async () => { + const res = await app.request("/v1/monitor/tcp", { + method: "POST", + headers: { + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(401); +}); diff --git a/apps/server/src/routes/v1/monitors/post_tcp.ts b/apps/server/src/routes/v1/monitors/post_tcp.ts new file mode 100644 index 000000000..a14ed2f12 --- /dev/null +++ b/apps/server/src/routes/v1/monitors/post_tcp.ts @@ -0,0 +1,108 @@ +import { createRoute, z } from "@hono/zod-openapi"; + +import { Events } from "@openstatus/analytics"; +import { and, db, eq, isNull, sql } from "@openstatus/db"; +import { monitor } from "@openstatus/db/src/schema"; + +import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; +import { trackMiddleware } from "@/libs/middlewares"; +import type { monitorsApi } from "./index"; +import { MonitorSchema, TCPMonitorSchema } from "./schema"; + +const postRoute = createRoute({ + method: "post", + tags: ["monitor"], + summary: "Create a tcp monitor", + path: "/tcp", + middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], + request: { + body: { + description: "The monitor to create", + content: { + "application/json": { + schema: TCPMonitorSchema, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: MonitorSchema, + }, + }, + description: "Create a monitor", + }, + ...openApiErrorResponses, + }, +}); + +export function registerPostMonitorTCP(api: typeof monitorsApi) { + return api.openapi(postRoute, async (c) => { + const workspaceId = c.get("workspace").id; + const limits = c.get("workspace").limits; + const input = c.req.valid("json"); + const count = ( + await db + .select({ count: sql`count(*)` }) + .from(monitor) + .where( + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), + ) + .all() + )[0].count; + + if (count >= limits.monitors) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more monitors", + }); + } + + if (!limits.periodicity.includes(input.frequency)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more periodicity", + }); + } + + for (const region of input.regions) { + if (!limits.regions.includes(region)) { + throw new OpenStatusApiError({ + code: "PAYMENT_REQUIRED", + message: "Upgrade for more regions", + }); + } + } + + const { request, regions, otelHeaders, ...rest } = input; + const otelHeadersEntries = otelHeaders + ? Object.entries(otelHeaders).map(([key, value]) => ({ + key: key, + value: value, + })) + : undefined; + + const _newMonitor = await db + .insert(monitor) + .values({ + ...rest, + url: `${request.host}:${request.port}`, + workspaceId: workspaceId, + regions: regions ? regions.join(",") : undefined, + headers: undefined, + assertions: undefined, + timeout: input.timeout || 45000, + otelHeaders: otelHeadersEntries + ? JSON.stringify(otelHeadersEntries) + : undefined, + }) + .returning() + .get(); + + const data = MonitorSchema.parse(_newMonitor); + + return c.json(data, 200); + }); +} diff --git a/apps/server/src/routes/v1/monitors/schema.ts b/apps/server/src/routes/v1/monitors/schema.ts index b7a08f7db..a9d980010 100644 --- a/apps/server/src/routes/v1/monitors/schema.ts +++ b/apps/server/src/routes/v1/monitors/schema.ts @@ -277,3 +277,118 @@ export const ResultRun = z.object({ }, timingSchema.nullable()) .optional(), }); + +const baseRequest = z.object({ + name: z.string().openapi({ + description: "Name of the monitor", + }), + description: z.string().optional(), + retry: z + .number() + .max(10) + .min(1) + .optional() + .openapi({ + description: "Number of retries to attempt", + examples: [1, 3, 5], + default: 3, + }), + degradedAfter: z + .number() + .min(0) + .optional() + .openapi({ + description: + "Time in milliseconds to wait before marking the request as degraded", + examples: [30000], + default: 30000, + }), + timeout: z + .number() + .min(0) + .optional() + .openapi({ + description: + "Time in milliseconds to wait before marking the request as timed out", + examples: [45000], + default: 45000, + }), + frequency: z.enum(["30s", "1m", "5m", "10m", "30m", "1h"]), + active: z.boolean().optional().openapi({ + description: "Whether the monitor is active", + default: false, + }), + public: z.boolean().optional().openapi({ + description: "Whether the monitor is public", + default: false, + }), + regions: z.array(z.enum(flyRegions)).openapi({ + description: "Regions to run the request in", + }), + otelEndpoint: z + .string() + .url() + .optional() + .openapi({ + description: "OTEL endpoint to send metrics to", + examples: ["https://otel.example.com"], + }), + otelHeaders: z + .record(z.string(), z.string()) + .optional() + .openapi({ + description: "Headers to send with the OTEL request", + examples: [{ "Content-Type": "application/json" }], + }), +}); + +const httpRequestSchema = z.object({ + method: z.enum(monitorMethods), + url: z + .string() + .url() + .openapi({ + description: "URL to request", + examples: ["https://openstat.us", "https://www.openstatus.dev"], + }), + headers: z + .record(z.string(), z.string()) + .optional() + .openapi({ + description: "Headers to send with the request", + examples: [{ "Content-Type": "application/json" }], + }), + body: z + .string() + .optional() + .openapi({ + description: "Body to send with the request", + examples: ['{ "key": "value" }', "Hello World"], + }), +}); + +const tcpRequestSchema = z.object({ + host: z.string().openapi({ + examples: ["example.com", "localhost"], + description: "Host to connect to", + }), + port: z.number().openapi({ + description: "Port to connect to", + examples: [80, 443, 1337], + }), +}); + +export const HTTPMonitorSchema = baseRequest.extend({ + assertions: z.array(assertion).optional().openapi({ + description: "Assertions to run on the response", + }), + request: httpRequestSchema.openapi({ + description: "The HTTP Request we are sending", + }), +}); + +export const TCPMonitorSchema = baseRequest.extend({ + request: tcpRequestSchema.openapi({ + description: "The TCP Request we are sending", + }), +});