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