Openstatus www.openstatus.dev

๐Ÿ”Œ endpoint monitor api per type (#1264)

* ๐Ÿšง endpoint

* ๐Ÿงช tests

* ๐Ÿšง fix

* ci: apply automated fixes

* ๐Ÿงช fix tests

* ci: apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Thibault Le Ouay
autofix-ci[bot]
and committed by
GitHub
6472c09d af8f20c4

+510
+4
apps/server/src/routes/v1/monitors/index.ts
··· 6 6 import { registerGetMonitor } from "./get"; 7 7 import { registerGetAllMonitors } from "./get_all"; 8 8 import { registerPostMonitor } from "./post"; 9 + import { registerPostMonitorHTTP } from "./post_http"; 10 + import { registerPostMonitorTCP } from "./post_tcp"; 9 11 import { registerPutMonitor } from "./put"; 10 12 import { registerGetMonitorResult } from "./results/get"; 11 13 import { registerRunMonitor } from "./run/post"; ··· 21 23 registerPutMonitor(monitorsApi); 22 24 registerDeleteMonitor(monitorsApi); 23 25 registerPostMonitor(monitorsApi); 26 + registerPostMonitorHTTP(monitorsApi); 27 + registerPostMonitorTCP(monitorsApi); 24 28 // 25 29 registerGetMonitorSummary(monitorsApi); 26 30 registerTriggerMonitor(monitorsApi);
+86
apps/server/src/routes/v1/monitors/post_http.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { MonitorSchema } from "./schema"; 5 + 6 + test("create a valid monitor", async () => { 7 + const res = await app.request("/v1/monitor/http", { 8 + method: "POST", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "content-type": "application/json", 12 + }, 13 + body: JSON.stringify({ 14 + frequency: "10m", 15 + name: "OpenStatus", 16 + description: "OpenStatus website", 17 + regions: ["ams", "gru"], 18 + request: { 19 + url: "https://www.openstatus.dev", 20 + method: "POST", 21 + body: '{"hello":"world"}', 22 + headers: { "content-type": "application/json" }, 23 + }, 24 + active: true, 25 + public: true, 26 + assertions: [ 27 + { 28 + type: "status", 29 + compare: "eq", 30 + target: 200, 31 + }, 32 + { type: "header", compare: "not_eq", key: "key", target: "value" }, 33 + ], 34 + }), 35 + }); 36 + 37 + const result = MonitorSchema.safeParse(await res.json()); 38 + 39 + expect(res.status).toBe(200); 40 + expect(result.success).toBe(true); 41 + }); 42 + 43 + test("create a status report with invalid payload should return 400", async () => { 44 + const res = await app.request("/v1/monitor/http", { 45 + method: "POST", 46 + headers: { 47 + "x-openstatus-key": "1", 48 + "content-type": "application/json", 49 + }, 50 + body: JSON.stringify({ 51 + frequency: "21m", 52 + name: "OpenStatus", 53 + description: "OpenStatus website", 54 + regions: ["ams", "gru"], 55 + request: { 56 + url: "https://www.openstatus.dev", 57 + method: "POST", 58 + body: '{"hello":"world"}', 59 + headers: { "content-type": "application/json" }, 60 + }, 61 + active: true, 62 + public: true, 63 + assertions: [ 64 + { 65 + type: "status", 66 + compare: "eq", 67 + target: 200, 68 + }, 69 + { type: "header", compare: "not_eq", key: "key", target: "value" }, 70 + ], 71 + }), 72 + }); 73 + 74 + expect(res.status).toBe(400); 75 + }); 76 + 77 + test("no auth key should return 401", async () => { 78 + const res = await app.request("/v1/monitor/http", { 79 + method: "POST", 80 + headers: { 81 + "content-type": "application/json", 82 + }, 83 + }); 84 + 85 + expect(res.status).toBe(401); 86 + });
+123
apps/server/src/routes/v1/monitors/post_http.ts
··· 1 + import { createRoute, z } from "@hono/zod-openapi"; 2 + 3 + import { Events } from "@openstatus/analytics"; 4 + import { and, db, eq, isNull, sql } from "@openstatus/db"; 5 + import { monitor } from "@openstatus/db/src/schema"; 6 + 7 + import { serialize } from "@openstatus/assertions"; 8 + 9 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 10 + import { trackMiddleware } from "@/libs/middlewares"; 11 + import type { monitorsApi } from "./index"; 12 + import { HTTPMonitorSchema, MonitorSchema } from "./schema"; 13 + import { getAssertions } from "./utils"; 14 + 15 + const postRoute = createRoute({ 16 + method: "post", 17 + tags: ["monitor"], 18 + summary: "Create a http monitor", 19 + path: "/http", 20 + middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], 21 + request: { 22 + body: { 23 + description: "The monitor to create", 24 + content: { 25 + "application/json": { 26 + schema: HTTPMonitorSchema, 27 + }, 28 + }, 29 + }, 30 + }, 31 + responses: { 32 + 200: { 33 + content: { 34 + "application/json": { 35 + schema: MonitorSchema, 36 + }, 37 + }, 38 + description: "Create a monitor", 39 + }, 40 + ...openApiErrorResponses, 41 + }, 42 + }); 43 + 44 + export function registerPostMonitorHTTP(api: typeof monitorsApi) { 45 + return api.openapi(postRoute, async (c) => { 46 + const workspaceId = c.get("workspace").id; 47 + const limits = c.get("workspace").limits; 48 + const input = c.req.valid("json"); 49 + const count = ( 50 + await db 51 + .select({ count: sql<number>`count(*)` }) 52 + .from(monitor) 53 + .where( 54 + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), 55 + ) 56 + .all() 57 + )[0].count; 58 + 59 + if (count >= limits.monitors) { 60 + throw new OpenStatusApiError({ 61 + code: "PAYMENT_REQUIRED", 62 + message: "Upgrade for more monitors", 63 + }); 64 + } 65 + 66 + if (!limits.periodicity.includes(input.frequency)) { 67 + throw new OpenStatusApiError({ 68 + code: "PAYMENT_REQUIRED", 69 + message: "Upgrade for more periodicity", 70 + }); 71 + } 72 + 73 + for (const region of input.regions) { 74 + if (!limits.regions.includes(region)) { 75 + throw new OpenStatusApiError({ 76 + code: "PAYMENT_REQUIRED", 77 + message: "Upgrade for more regions", 78 + }); 79 + } 80 + } 81 + 82 + const { request, regions, assertions, otelHeaders, ...rest } = input; 83 + 84 + const headers = input.request.headers 85 + ? Object.entries(input.request.headers) 86 + : undefined; 87 + 88 + const otelHeadersEntries = otelHeaders 89 + ? Object.entries(otelHeaders).map(([key, value]) => ({ 90 + key: key, 91 + value: value, 92 + })) 93 + : undefined; 94 + const headersEntries = headers 95 + ? headers.map(([key, value]) => ({ key: key, value: value })) 96 + : undefined; 97 + const assert = assertions ? getAssertions(assertions) : []; 98 + 99 + const _newMonitor = await db 100 + .insert(monitor) 101 + .values({ 102 + ...rest, 103 + periodicity: input.frequency, 104 + url: request.url, 105 + method: request.method, 106 + body: request.body, 107 + workspaceId: workspaceId, 108 + regions: regions ? regions.join(",") : undefined, 109 + headers: headersEntries ? JSON.stringify(headersEntries) : undefined, 110 + assertions: assert.length > 0 ? serialize(assert) : undefined, 111 + timeout: input.timeout || 45000, 112 + otelHeaders: otelHeadersEntries 113 + ? JSON.stringify(otelHeadersEntries) 114 + : undefined, 115 + }) 116 + .returning() 117 + .get(); 118 + 119 + const data = MonitorSchema.parse(_newMonitor); 120 + 121 + return c.json(data, 200); 122 + }); 123 + }
+74
apps/server/src/routes/v1/monitors/post_tcp.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + 3 + import { app } from "@/index"; 4 + import { MonitorSchema } from "./schema"; 5 + 6 + test("create a valid monitor", async () => { 7 + const res = await app.request("/v1/monitor/tcp", { 8 + method: "POST", 9 + headers: { 10 + "x-openstatus-key": "1", 11 + "content-type": "application/json", 12 + }, 13 + body: JSON.stringify({ 14 + frequency: "10m", 15 + name: "OpenStatus", 16 + description: "OpenStatus website", 17 + regions: ["ams", "gru"], 18 + request: { 19 + host: "openstatus.dev", 20 + port: 443, 21 + }, 22 + active: true, 23 + public: true, 24 + assertions: [ 25 + { 26 + type: "status", 27 + compare: "eq", 28 + target: 200, 29 + }, 30 + { type: "header", compare: "not_eq", key: "key", target: "value" }, 31 + ], 32 + }), 33 + }); 34 + 35 + const result = MonitorSchema.safeParse(await res.json()); 36 + 37 + expect(res.status).toBe(200); 38 + expect(result.success).toBe(true); 39 + }); 40 + 41 + test("create a status report with invalid payload should return 400", async () => { 42 + const res = await app.request("/v1/monitor/tcp", { 43 + method: "POST", 44 + headers: { 45 + "x-openstatus-key": "1", 46 + "content-type": "application/json", 47 + }, 48 + body: JSON.stringify({ 49 + frequency: "21m", 50 + name: "OpenStatus", 51 + description: "OpenStatus website", 52 + regions: ["ams", "gru"], 53 + request: { 54 + host: "openstatus.dev", 55 + port: 443, 56 + }, 57 + active: true, 58 + public: true, 59 + }), 60 + }); 61 + 62 + expect(res.status).toBe(400); 63 + }); 64 + 65 + test("no auth key should return 401", async () => { 66 + const res = await app.request("/v1/monitor/tcp", { 67 + method: "POST", 68 + headers: { 69 + "content-type": "application/json", 70 + }, 71 + }); 72 + 73 + expect(res.status).toBe(401); 74 + });
+108
apps/server/src/routes/v1/monitors/post_tcp.ts
··· 1 + import { createRoute, z } from "@hono/zod-openapi"; 2 + 3 + import { Events } from "@openstatus/analytics"; 4 + import { and, db, eq, isNull, sql } from "@openstatus/db"; 5 + import { monitor } from "@openstatus/db/src/schema"; 6 + 7 + import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; 8 + import { trackMiddleware } from "@/libs/middlewares"; 9 + import type { monitorsApi } from "./index"; 10 + import { MonitorSchema, TCPMonitorSchema } from "./schema"; 11 + 12 + const postRoute = createRoute({ 13 + method: "post", 14 + tags: ["monitor"], 15 + summary: "Create a tcp monitor", 16 + path: "/tcp", 17 + middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], 18 + request: { 19 + body: { 20 + description: "The monitor to create", 21 + content: { 22 + "application/json": { 23 + schema: TCPMonitorSchema, 24 + }, 25 + }, 26 + }, 27 + }, 28 + responses: { 29 + 200: { 30 + content: { 31 + "application/json": { 32 + schema: MonitorSchema, 33 + }, 34 + }, 35 + description: "Create a monitor", 36 + }, 37 + ...openApiErrorResponses, 38 + }, 39 + }); 40 + 41 + export function registerPostMonitorTCP(api: typeof monitorsApi) { 42 + return api.openapi(postRoute, async (c) => { 43 + const workspaceId = c.get("workspace").id; 44 + const limits = c.get("workspace").limits; 45 + const input = c.req.valid("json"); 46 + const count = ( 47 + await db 48 + .select({ count: sql<number>`count(*)` }) 49 + .from(monitor) 50 + .where( 51 + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), 52 + ) 53 + .all() 54 + )[0].count; 55 + 56 + if (count >= limits.monitors) { 57 + throw new OpenStatusApiError({ 58 + code: "PAYMENT_REQUIRED", 59 + message: "Upgrade for more monitors", 60 + }); 61 + } 62 + 63 + if (!limits.periodicity.includes(input.frequency)) { 64 + throw new OpenStatusApiError({ 65 + code: "PAYMENT_REQUIRED", 66 + message: "Upgrade for more periodicity", 67 + }); 68 + } 69 + 70 + for (const region of input.regions) { 71 + if (!limits.regions.includes(region)) { 72 + throw new OpenStatusApiError({ 73 + code: "PAYMENT_REQUIRED", 74 + message: "Upgrade for more regions", 75 + }); 76 + } 77 + } 78 + 79 + const { request, regions, otelHeaders, ...rest } = input; 80 + const otelHeadersEntries = otelHeaders 81 + ? Object.entries(otelHeaders).map(([key, value]) => ({ 82 + key: key, 83 + value: value, 84 + })) 85 + : undefined; 86 + 87 + const _newMonitor = await db 88 + .insert(monitor) 89 + .values({ 90 + ...rest, 91 + url: `${request.host}:${request.port}`, 92 + workspaceId: workspaceId, 93 + regions: regions ? regions.join(",") : undefined, 94 + headers: undefined, 95 + assertions: undefined, 96 + timeout: input.timeout || 45000, 97 + otelHeaders: otelHeadersEntries 98 + ? JSON.stringify(otelHeadersEntries) 99 + : undefined, 100 + }) 101 + .returning() 102 + .get(); 103 + 104 + const data = MonitorSchema.parse(_newMonitor); 105 + 106 + return c.json(data, 200); 107 + }); 108 + }
+115
apps/server/src/routes/v1/monitors/schema.ts
··· 277 277 }, timingSchema.nullable()) 278 278 .optional(), 279 279 }); 280 + 281 + const baseRequest = z.object({ 282 + name: z.string().openapi({ 283 + description: "Name of the monitor", 284 + }), 285 + description: z.string().optional(), 286 + retry: z 287 + .number() 288 + .max(10) 289 + .min(1) 290 + .optional() 291 + .openapi({ 292 + description: "Number of retries to attempt", 293 + examples: [1, 3, 5], 294 + default: 3, 295 + }), 296 + degradedAfter: z 297 + .number() 298 + .min(0) 299 + .optional() 300 + .openapi({ 301 + description: 302 + "Time in milliseconds to wait before marking the request as degraded", 303 + examples: [30000], 304 + default: 30000, 305 + }), 306 + timeout: z 307 + .number() 308 + .min(0) 309 + .optional() 310 + .openapi({ 311 + description: 312 + "Time in milliseconds to wait before marking the request as timed out", 313 + examples: [45000], 314 + default: 45000, 315 + }), 316 + frequency: z.enum(["30s", "1m", "5m", "10m", "30m", "1h"]), 317 + active: z.boolean().optional().openapi({ 318 + description: "Whether the monitor is active", 319 + default: false, 320 + }), 321 + public: z.boolean().optional().openapi({ 322 + description: "Whether the monitor is public", 323 + default: false, 324 + }), 325 + regions: z.array(z.enum(flyRegions)).openapi({ 326 + description: "Regions to run the request in", 327 + }), 328 + otelEndpoint: z 329 + .string() 330 + .url() 331 + .optional() 332 + .openapi({ 333 + description: "OTEL endpoint to send metrics to", 334 + examples: ["https://otel.example.com"], 335 + }), 336 + otelHeaders: z 337 + .record(z.string(), z.string()) 338 + .optional() 339 + .openapi({ 340 + description: "Headers to send with the OTEL request", 341 + examples: [{ "Content-Type": "application/json" }], 342 + }), 343 + }); 344 + 345 + const httpRequestSchema = z.object({ 346 + method: z.enum(monitorMethods), 347 + url: z 348 + .string() 349 + .url() 350 + .openapi({ 351 + description: "URL to request", 352 + examples: ["https://openstat.us", "https://www.openstatus.dev"], 353 + }), 354 + headers: z 355 + .record(z.string(), z.string()) 356 + .optional() 357 + .openapi({ 358 + description: "Headers to send with the request", 359 + examples: [{ "Content-Type": "application/json" }], 360 + }), 361 + body: z 362 + .string() 363 + .optional() 364 + .openapi({ 365 + description: "Body to send with the request", 366 + examples: ['{ "key": "value" }', "Hello World"], 367 + }), 368 + }); 369 + 370 + const tcpRequestSchema = z.object({ 371 + host: z.string().openapi({ 372 + examples: ["example.com", "localhost"], 373 + description: "Host to connect to", 374 + }), 375 + port: z.number().openapi({ 376 + description: "Port to connect to", 377 + examples: [80, 443, 1337], 378 + }), 379 + }); 380 + 381 + export const HTTPMonitorSchema = baseRequest.extend({ 382 + assertions: z.array(assertion).optional().openapi({ 383 + description: "Assertions to run on the response", 384 + }), 385 + request: httpRequestSchema.openapi({ 386 + description: "The HTTP Request we are sending", 387 + }), 388 + }); 389 + 390 + export const TCPMonitorSchema = baseRequest.extend({ 391 + request: tcpRequestSchema.openapi({ 392 + description: "The TCP Request we are sending", 393 + }), 394 + });