Openstatus
www.openstatus.dev
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}