Openstatus www.openstatus.dev
at 4ee8f50e37873ebf4d80dbb5bc76fdb40a01a3ea 309 lines 7.2 kB view raw
1import { JSONPath } from "jsonpath-plus"; 2import { z } from "zod"; 3 4import type { Assertion, AssertionRequest, AssertionResult } from "./types"; 5 6export const stringCompare = z.enum([ 7 "contains", 8 "not_contains", 9 "eq", 10 "not_eq", 11 "empty", 12 "not_empty", 13 "gt", 14 "gte", 15 "lt", 16 "lte", 17]); 18export const numberCompare = z.enum(["eq", "not_eq", "gt", "gte", "lt", "lte"]); 19 20export const recordCompare = z.enum([ 21 "contains", 22 "not_contains", 23 "eq", 24 "not_eq", 25]); 26 27function evaluateNumber( 28 value: number, 29 compare: z.infer<typeof numberCompare>, 30 target: number, 31): AssertionResult { 32 switch (compare) { 33 case "eq": 34 if (value !== target) { 35 return { 36 success: false, 37 message: `Expected ${value} to be equal to ${target}`, 38 }; 39 } 40 break; 41 case "not_eq": 42 if (value === target) { 43 return { 44 success: false, 45 message: `Expected ${value} to not be equal to ${target}`, 46 }; 47 } 48 break; 49 case "gt": 50 if (value <= target) { 51 return { 52 success: false, 53 message: `Expected ${value} to be greater than ${target}`, 54 }; 55 } 56 break; 57 case "gte": 58 if (value < target) { 59 return { 60 success: false, 61 message: `Expected ${value} to be greater than or equal to ${target}`, 62 }; 63 } 64 break; 65 case "lt": 66 if (value >= target) { 67 return { 68 success: false, 69 message: `Expected ${value} to be less than ${target}`, 70 }; 71 } 72 break; 73 case "lte": 74 if (value > target) { 75 return { 76 success: false, 77 message: `Expected ${value} to be less than or equal to ${target}`, 78 }; 79 } 80 break; 81 } 82 return { success: true }; 83} 84 85function evaluateString( 86 value: string, 87 compare: z.infer<typeof stringCompare>, 88 target: string, 89): AssertionResult { 90 switch (compare) { 91 case "contains": 92 if (!value.includes(target)) { 93 return { 94 success: false, 95 message: `Expected ${value} to contain ${target}`, 96 }; 97 } 98 break; 99 case "not_contains": 100 if (value.includes(target)) { 101 return { 102 success: false, 103 message: `Expected ${value} to not contain ${target}`, 104 }; 105 } 106 break; 107 case "empty": 108 if (value !== "") { 109 return { success: false, message: `Expected ${value} to be empty` }; 110 } 111 break; 112 case "not_empty": 113 if (value === "") { 114 return { success: false, message: `Expected ${value} to not be empty` }; 115 } 116 break; 117 case "eq": 118 if (value !== target) { 119 return { 120 success: false, 121 message: `Expected ${value} to be equal to ${target}`, 122 }; 123 } 124 break; 125 case "not_eq": 126 if (value === target) { 127 return { 128 success: false, 129 message: `Expected ${value} to not be equal to ${target}`, 130 }; 131 } 132 break; 133 case "gt": 134 if (value <= target) { 135 return { 136 success: false, 137 message: `Expected ${value} to be greater than ${target}`, 138 }; 139 } 140 break; 141 case "gte": 142 if (value < target) { 143 return { 144 success: false, 145 message: `Expected ${value} to be greater than or equal to ${target}`, 146 }; 147 } 148 break; 149 case "lt": 150 if (value >= target) { 151 return { 152 success: false, 153 message: `Expected ${value} to be less than ${target}`, 154 }; 155 } 156 break; 157 case "lte": 158 if (value > target) { 159 return { 160 success: false, 161 message: `Expected ${value} to be less than or equal to ${target}`, 162 }; 163 } 164 break; 165 } 166 return { success: true }; 167} 168 169export const base = z 170 .object({ 171 version: z.enum(["v1"]).default("v1"), 172 type: z.string(), 173 }) 174 .passthrough(); 175export const statusAssertion = base.merge( 176 z.object({ 177 type: z.literal("status"), 178 compare: numberCompare, 179 target: z.number().int().positive(), 180 }), 181); 182 183export const headerAssertion = base.merge( 184 z.object({ 185 type: z.literal("header"), 186 compare: stringCompare, 187 key: z.string(), 188 target: z.string(), 189 }), 190); 191 192export const textBodyAssertion = base.merge( 193 z.object({ 194 type: z.literal("textBody"), 195 compare: stringCompare, 196 target: z.string(), 197 }), 198); 199 200export const jsonBodyAssertion = base.merge( 201 z.object({ 202 type: z.literal("jsonBody"), 203 path: z.string(), // https://www.npmjs.com/package/jsonpath-plus 204 compare: stringCompare, 205 target: z.string(), 206 }), 207); 208 209export const recordAssertion = base.merge( 210 z.object({ 211 type: z.literal("dnsRecord"), 212 record: z.enum(["A", "AAAA", "CNAME", "MX", "TXT", "NS"]), 213 compare: recordCompare, 214 target: z.string(), 215 }), 216); 217 218export const assertion = z.discriminatedUnion("type", [ 219 statusAssertion, 220 headerAssertion, 221 textBodyAssertion, 222 jsonBodyAssertion, 223]); 224 225export class StatusAssertion implements Assertion { 226 readonly schema: z.infer<typeof statusAssertion>; 227 228 constructor(schema: z.infer<typeof statusAssertion>) { 229 this.schema = schema; 230 } 231 232 public assert(req: AssertionRequest): AssertionResult { 233 const { success, message } = evaluateNumber( 234 req.status, 235 this.schema.compare, 236 this.schema.target, 237 ); 238 if (success) { 239 return { success }; 240 } 241 return { success, message: `Status: ${message}` }; 242 } 243} 244 245export class HeaderAssertion { 246 readonly schema: z.infer<typeof headerAssertion>; 247 248 constructor(schema: z.infer<typeof headerAssertion>) { 249 this.schema = schema; 250 } 251 252 public assert(req: AssertionRequest): AssertionResult { 253 const { success, message } = evaluateString( 254 req.header[this.schema.key], 255 this.schema.compare, 256 this.schema.target, 257 ); 258 if (success) { 259 return { success }; 260 } 261 return { success, message: `Header ${this.schema.key}: ${message}` }; 262 } 263} 264 265export class TextBodyAssertion { 266 readonly schema: z.infer<typeof textBodyAssertion>; 267 268 constructor(schema: z.infer<typeof textBodyAssertion>) { 269 this.schema = schema; 270 } 271 272 public assert(req: AssertionRequest): AssertionResult { 273 const { success, message } = evaluateString( 274 req.body, 275 this.schema.compare, 276 this.schema.target, 277 ); 278 if (success) { 279 return { success }; 280 } 281 return { success, message: `Body: ${message}` }; 282 } 283} 284export class JsonBodyAssertion implements Assertion { 285 readonly schema: z.infer<typeof jsonBodyAssertion>; 286 287 constructor(schema: z.infer<typeof jsonBodyAssertion>) { 288 this.schema = schema; 289 } 290 291 public assert(req: AssertionRequest): AssertionResult { 292 try { 293 const json = JSON.parse(req.body); 294 const value = JSONPath({ path: this.schema.path, json }); 295 const { success, message } = evaluateString( 296 value, 297 this.schema.compare, 298 this.schema.target, 299 ); 300 if (success) { 301 return { success }; 302 } 303 return { success, message: `Body: ${message}` }; 304 } catch (_e) { 305 console.error("Unable to parse json"); 306 return { success: false, message: "Unable to parse json" }; 307 } 308 } 309}