Openstatus www.openstatus.dev
at main 406 lines 9.9 kB view raw
1import { JSONPath } from "jsonpath-plus"; 2import { z } from "zod"; 3 4import { isDnsAssertionRequest, isHttpAssertionRequest } from "./type-guards"; 5import type { Assertion, AssertionRequest, AssertionResult } from "./types"; 6 7export const stringCompare = z.enum([ 8 "contains", 9 "not_contains", 10 "eq", 11 "not_eq", 12 "empty", 13 "not_empty", 14 "gt", 15 "gte", 16 "lt", 17 "lte", 18]); 19export const numberCompare = z.enum(["eq", "not_eq", "gt", "gte", "lt", "lte"]); 20 21export const recordCompare = z.enum([ 22 "contains", 23 "not_contains", 24 "eq", 25 "not_eq", 26]); 27 28function evaluateNumber( 29 value: number, 30 compare: z.infer<typeof numberCompare>, 31 target: number, 32): AssertionResult { 33 switch (compare) { 34 case "eq": 35 if (value !== target) { 36 return { 37 success: false, 38 message: `Expected ${value} to be equal to ${target}`, 39 }; 40 } 41 break; 42 case "not_eq": 43 if (value === target) { 44 return { 45 success: false, 46 message: `Expected ${value} to not be equal to ${target}`, 47 }; 48 } 49 break; 50 case "gt": 51 if (value <= target) { 52 return { 53 success: false, 54 message: `Expected ${value} to be greater than ${target}`, 55 }; 56 } 57 break; 58 case "gte": 59 if (value < target) { 60 return { 61 success: false, 62 message: `Expected ${value} to be greater than or equal to ${target}`, 63 }; 64 } 65 break; 66 case "lt": 67 if (value >= target) { 68 return { 69 success: false, 70 message: `Expected ${value} to be less than ${target}`, 71 }; 72 } 73 break; 74 case "lte": 75 if (value > target) { 76 return { 77 success: false, 78 message: `Expected ${value} to be less than or equal to ${target}`, 79 }; 80 } 81 break; 82 } 83 return { success: true }; 84} 85 86function evaluateString( 87 value: string, 88 compare: z.infer<typeof stringCompare>, 89 target: string, 90): AssertionResult { 91 switch (compare) { 92 case "contains": 93 if (!value.includes(target)) { 94 return { 95 success: false, 96 message: `Expected ${value} to contain ${target}`, 97 }; 98 } 99 break; 100 case "not_contains": 101 if (value.includes(target)) { 102 return { 103 success: false, 104 message: `Expected ${value} to not contain ${target}`, 105 }; 106 } 107 break; 108 case "empty": 109 if (value !== "") { 110 return { success: false, message: `Expected ${value} to be empty` }; 111 } 112 break; 113 case "not_empty": 114 if (value === "") { 115 return { success: false, message: `Expected ${value} to not be empty` }; 116 } 117 break; 118 case "eq": 119 if (value !== target) { 120 return { 121 success: false, 122 message: `Expected ${value} to be equal to ${target}`, 123 }; 124 } 125 break; 126 case "not_eq": 127 if (value === target) { 128 return { 129 success: false, 130 message: `Expected ${value} to not be equal to ${target}`, 131 }; 132 } 133 break; 134 case "gt": 135 if (value <= target) { 136 return { 137 success: false, 138 message: `Expected ${value} to be greater than ${target}`, 139 }; 140 } 141 break; 142 case "gte": 143 if (value < target) { 144 return { 145 success: false, 146 message: `Expected ${value} to be greater than or equal to ${target}`, 147 }; 148 } 149 break; 150 case "lt": 151 if (value >= target) { 152 return { 153 success: false, 154 message: `Expected ${value} to be less than ${target}`, 155 }; 156 } 157 break; 158 case "lte": 159 if (value > target) { 160 return { 161 success: false, 162 message: `Expected ${value} to be less than or equal to ${target}`, 163 }; 164 } 165 break; 166 } 167 return { success: true }; 168} 169 170function evaluateRecord( 171 values: string[], 172 compare: z.infer<typeof recordCompare>, 173 target: string, 174): AssertionResult { 175 const valuesString = values.join(", "); 176 177 switch (compare) { 178 case "contains": 179 if (!values.some((v) => v.includes(target))) { 180 return { 181 success: false, 182 message: `Expected DNS records [${valuesString}] to contain ${target}`, 183 }; 184 } 185 break; 186 case "not_contains": 187 if (values.some((v) => v.includes(target))) { 188 return { 189 success: false, 190 message: `Expected DNS records [${valuesString}] to not contain ${target}`, 191 }; 192 } 193 break; 194 case "eq": 195 if (!values.includes(target)) { 196 return { 197 success: false, 198 message: `Expected DNS records [${valuesString}] to equal ${target}`, 199 }; 200 } 201 break; 202 case "not_eq": 203 if (values.includes(target)) { 204 return { 205 success: false, 206 message: `Expected DNS records [${valuesString}] to not equal ${target}`, 207 }; 208 } 209 break; 210 } 211 return { success: true }; 212} 213 214export const base = z.looseObject({ 215 version: z.enum(["v1"]).prefault("v1"), 216 type: z.string(), 217}); 218export const statusAssertion = base.extend( 219 z.object({ 220 type: z.literal("status"), 221 compare: numberCompare, 222 target: z.int().positive(), 223 }).shape, 224); 225 226export const headerAssertion = base.extend( 227 z.object({ 228 type: z.literal("header"), 229 compare: stringCompare, 230 key: z.string(), 231 target: z.string(), 232 }).shape, 233); 234 235export const textBodyAssertion = base.extend( 236 z.object({ 237 type: z.literal("textBody"), 238 compare: stringCompare, 239 target: z.string(), 240 }).shape, 241); 242 243export const jsonBodyAssertion = base.extend( 244 z.object({ 245 type: z.literal("jsonBody"), 246 path: z.string(), // https://www.npmjs.com/package/jsonpath-plus 247 compare: stringCompare, 248 target: z.string(), 249 }).shape, 250); 251 252export const dnsRecords = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; 253 254export const recordAssertion = base.extend( 255 z.object({ 256 type: z.literal("dnsRecord"), 257 key: z.enum(dnsRecords), 258 compare: recordCompare, 259 target: z.string(), 260 }).shape, 261); 262 263export const assertion = z.discriminatedUnion("type", [ 264 statusAssertion, 265 headerAssertion, 266 textBodyAssertion, 267 jsonBodyAssertion, 268 recordAssertion, 269]); 270 271export class StatusAssertion implements Assertion { 272 readonly schema: z.infer<typeof statusAssertion>; 273 274 constructor(schema: z.infer<typeof statusAssertion>) { 275 this.schema = schema; 276 } 277 278 public assert(req: AssertionRequest): AssertionResult { 279 if (!isHttpAssertionRequest(req)) { 280 return { 281 success: false, 282 message: "Invalid request type for status assertion", 283 }; 284 } 285 const { success, message } = evaluateNumber( 286 req.status, 287 this.schema.compare, 288 this.schema.target, 289 ); 290 if (success) { 291 return { success }; 292 } 293 return { success, message: `Status: ${message}` }; 294 } 295} 296 297export class HeaderAssertion implements Assertion { 298 readonly schema: z.infer<typeof headerAssertion>; 299 300 constructor(schema: z.infer<typeof headerAssertion>) { 301 this.schema = schema; 302 } 303 304 public assert(req: AssertionRequest): AssertionResult { 305 if (!isHttpAssertionRequest(req)) { 306 return { 307 success: false, 308 message: "Invalid request type for header assertion", 309 }; 310 } 311 const { success, message } = evaluateString( 312 req.header[this.schema.key], 313 this.schema.compare, 314 this.schema.target, 315 ); 316 if (success) { 317 return { success }; 318 } 319 return { success, message: `Header ${this.schema.key}: ${message}` }; 320 } 321} 322 323export class TextBodyAssertion implements Assertion { 324 readonly schema: z.infer<typeof textBodyAssertion>; 325 326 constructor(schema: z.infer<typeof textBodyAssertion>) { 327 this.schema = schema; 328 } 329 330 public assert(req: AssertionRequest): AssertionResult { 331 if (!isHttpAssertionRequest(req)) { 332 return { 333 success: false, 334 message: "Invalid request type for text body assertion", 335 }; 336 } 337 const { success, message } = evaluateString( 338 req.body, 339 this.schema.compare, 340 this.schema.target, 341 ); 342 if (success) { 343 return { success }; 344 } 345 return { success, message: `Body: ${message}` }; 346 } 347} 348export class JsonBodyAssertion implements Assertion { 349 readonly schema: z.infer<typeof jsonBodyAssertion>; 350 351 constructor(schema: z.infer<typeof jsonBodyAssertion>) { 352 this.schema = schema; 353 } 354 355 public assert(req: AssertionRequest): AssertionResult { 356 if (!isHttpAssertionRequest(req)) { 357 return { 358 success: false, 359 message: "Invalid request type for JSON body assertion", 360 }; 361 } 362 try { 363 const json = JSON.parse(req.body); 364 const value = JSONPath({ path: this.schema.path, json }); 365 const { success, message } = evaluateString( 366 value, 367 this.schema.compare, 368 this.schema.target, 369 ); 370 if (success) { 371 return { success }; 372 } 373 return { success, message: `Body: ${message}` }; 374 } catch (_e) { 375 console.error("Unable to parse json"); 376 return { success: false, message: "Unable to parse json" }; 377 } 378 } 379} 380 381export class DnsRecordAssertion implements Assertion { 382 readonly schema: z.infer<typeof recordAssertion>; 383 384 constructor(schema: z.infer<typeof recordAssertion>) { 385 this.schema = schema; 386 } 387 388 public assert(req: AssertionRequest): AssertionResult { 389 if (!isDnsAssertionRequest(req)) { 390 return { 391 success: false, 392 message: "Invalid request type for DNS record assertion", 393 }; 394 } 395 const records = req.records[this.schema.key] || []; 396 const { success, message } = evaluateRecord( 397 records, 398 this.schema.compare, 399 this.schema.target, 400 ); 401 if (success) { 402 return { success }; 403 } 404 return { success, message: `DNS Record ${this.schema.key}: ${message}` }; 405 } 406}