@checkstack/healthcheck-http-backend 0.0.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/strategy.ts CHANGED
@@ -1,134 +1,51 @@
1
- import { JSONPath } from "jsonpath-plus";
2
1
  import {
3
2
  HealthCheckStrategy,
4
- HealthCheckResult,
5
3
  HealthCheckRunForAggregation,
6
4
  Versioned,
7
5
  z,
8
- numericField,
9
- timeThresholdField,
10
- stringField,
11
- evaluateAssertions,
6
+ type ConnectedClient,
12
7
  } from "@checkstack/backend-api";
13
8
  import {
14
9
  healthResultNumber,
15
10
  healthResultString,
16
11
  } from "@checkstack/healthcheck-common";
12
+ import type {
13
+ HttpTransportClient,
14
+ HttpRequest,
15
+ HttpResponse,
16
+ } from "./transport-client";
17
17
 
18
18
  // ============================================================================
19
19
  // SCHEMAS
20
20
  // ============================================================================
21
21
 
22
- /**
23
- * Header configuration for custom HTTP headers.
24
- */
25
- export const httpHeaderSchema = z.object({
26
- name: z.string().min(1).describe("Header name"),
27
- value: z.string().describe("Header value"),
28
- });
29
-
30
- /**
31
- * JSONPath assertion for response body validation.
32
- * Supports dynamic operators with runtime type coercion.
33
- */
34
- const jsonPathAssertionSchema = z.object({
35
- field: z.literal("jsonPath"),
36
- path: z
37
- .string()
38
- .describe("JSONPath expression (e.g. $.status, $.data[0].id)"),
39
- operator: z.enum([
40
- "equals",
41
- "notEquals",
42
- "contains",
43
- "startsWith",
44
- "endsWith",
45
- "matches",
46
- "exists",
47
- "notExists",
48
- "lessThan",
49
- "lessThanOrEqual",
50
- "greaterThan",
51
- "greaterThanOrEqual",
52
- ]),
53
- value: z
54
- .string()
55
- .optional()
56
- .describe("Expected value (not needed for exists/notExists)"),
57
- });
58
-
59
- /**
60
- * Response header assertion schema.
61
- * Check for specific header values in the response.
62
- */
63
- const headerAssertionSchema = z.object({
64
- field: z.literal("header"),
65
- headerName: z.string().describe("Response header name to check"),
66
- operator: z.enum([
67
- "equals",
68
- "notEquals",
69
- "contains",
70
- "startsWith",
71
- "endsWith",
72
- "exists",
73
- ]),
74
- value: z.string().optional().describe("Expected header value"),
75
- });
76
-
77
- /**
78
- * HTTP health check assertion schema using discriminated union.
79
- *
80
- * Assertions validate the result of a check:
81
- * - statusCode: Validate HTTP status code
82
- * - responseTime: Validate response latency
83
- * - contentType: Validate Content-Type header
84
- * - header: Validate any response header
85
- * - jsonPath: Validate JSON response body content
86
- */
87
- const httpAssertionSchema = z.discriminatedUnion("field", [
88
- numericField("statusCode", { min: 100, max: 599 }),
89
- timeThresholdField("responseTime"),
90
- stringField("contentType"),
91
- headerAssertionSchema,
92
- jsonPathAssertionSchema,
93
- ]);
94
-
95
- export type HttpAssertion = z.infer<typeof httpAssertionSchema>;
96
-
97
22
  /**
98
23
  * HTTP health check configuration schema.
99
- *
100
- * Config defines HOW to run the check (connection details, request setup).
101
- * Assertions define WHAT to validate in the result.
24
+ * Global defaults only - action params moved to RequestCollector.
102
25
  */
103
26
  export const httpHealthCheckConfigSchema = z.object({
104
- url: z.string().url().describe("The full URL of the endpoint to check."),
105
- method: z
106
- .enum(["GET", "POST", "PUT", "DELETE", "HEAD"])
107
- .default("GET")
108
- .describe("The HTTP method to use for the request."),
109
- headers: z
110
- .array(httpHeaderSchema)
111
- .optional()
112
- .describe("Custom HTTP headers to send with the request."),
113
27
  timeout: z
114
28
  .number()
29
+ .int()
115
30
  .min(100)
116
- .default(5000)
117
- .describe("Maximum time in milliseconds to wait for a response."),
118
- body: z
119
- .string()
120
- .optional()
121
- .describe(
122
- "Optional request payload body (e.g. JSON for POST requests). [textarea]"
123
- ),
124
- assertions: z
125
- .array(httpAssertionSchema)
126
- .optional()
127
- .describe("Validation conditions for the response."),
31
+ .default(30_000)
32
+ .describe("Default request timeout in milliseconds"),
128
33
  });
129
34
 
130
35
  export type HttpHealthCheckConfig = z.infer<typeof httpHealthCheckConfigSchema>;
131
36
 
37
+ // Legacy config types for migrations
38
+ interface HttpConfigV1 {
39
+ url: string;
40
+ method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
41
+ headers?: { name: string; value: string }[];
42
+ body?: string;
43
+ }
44
+
45
+ interface HttpConfigV2 extends HttpConfigV1 {
46
+ timeout: number;
47
+ }
48
+
132
49
  /** Per-run result metadata */
133
50
  const httpResultMetadataSchema = z.object({
134
51
  statusCode: healthResultNumber({
@@ -139,14 +56,13 @@ const httpResultMetadataSchema = z.object({
139
56
  "x-chart-type": "counter",
140
57
  "x-chart-label": "Content Type",
141
58
  }).optional(),
142
- failedAssertion: httpAssertionSchema.optional(),
143
59
  error: healthResultString({
144
60
  "x-chart-type": "status",
145
61
  "x-chart-label": "Error",
146
62
  }).optional(),
147
63
  });
148
64
 
149
- export type HttpResultMetadata = z.infer<typeof httpResultMetadataSchema>;
65
+ type HttpResultMetadata = z.infer<typeof httpResultMetadataSchema>;
150
66
 
151
67
  /** Aggregated metadata for buckets */
152
68
  const httpAggregatedMetadataSchema = z.object({
@@ -154,35 +70,62 @@ const httpAggregatedMetadataSchema = z.object({
154
70
  "x-chart-type": "pie",
155
71
  "x-chart-label": "Status Code Distribution",
156
72
  }),
73
+ successRate: healthResultNumber({
74
+ "x-chart-type": "gauge",
75
+ "x-chart-label": "Success Rate (%)",
76
+ }),
157
77
  errorCount: healthResultNumber({
158
78
  "x-chart-type": "counter",
159
79
  "x-chart-label": "Errors",
160
80
  }),
161
81
  });
162
82
 
163
- export type HttpAggregatedMetadata = z.infer<
164
- typeof httpAggregatedMetadataSchema
165
- >;
83
+ type HttpAggregatedMetadata = z.infer<typeof httpAggregatedMetadataSchema>;
84
+
85
+ // ============================================================================
86
+ // STRATEGY
87
+ // ============================================================================
166
88
 
167
89
  export class HttpHealthCheckStrategy
168
90
  implements
169
91
  HealthCheckStrategy<
170
92
  HttpHealthCheckConfig,
93
+ HttpTransportClient,
171
94
  HttpResultMetadata,
172
95
  HttpAggregatedMetadata
173
96
  >
174
97
  {
175
98
  id = "http";
176
99
  displayName = "HTTP/HTTPS Health Check";
177
- description = "HTTP endpoint health monitoring with flexible assertions";
100
+ description = "HTTP endpoint health monitoring";
178
101
 
179
102
  config: Versioned<HttpHealthCheckConfig> = new Versioned({
180
- version: 2, // Bumped for breaking change
103
+ version: 3, // v3 for createClient pattern with action params moved to RequestCollector
181
104
  schema: httpHealthCheckConfigSchema,
105
+ migrations: [
106
+ {
107
+ fromVersion: 1,
108
+ toVersion: 2,
109
+ description: "Add timeout field",
110
+ migrate: (data: HttpConfigV1): HttpConfigV2 => ({
111
+ ...data,
112
+ timeout: 30_000,
113
+ }),
114
+ },
115
+ {
116
+ fromVersion: 2,
117
+ toVersion: 3,
118
+ description:
119
+ "Remove url/method/headers/body (moved to RequestCollector)",
120
+ migrate: (data: HttpConfigV2): HttpHealthCheckConfig => ({
121
+ timeout: data.timeout,
122
+ }),
123
+ },
124
+ ],
182
125
  });
183
126
 
184
127
  result: Versioned<HttpResultMetadata> = new Versioned({
185
- version: 2,
128
+ version: 3,
186
129
  schema: httpResultMetadataSchema,
187
130
  });
188
131
 
@@ -196,10 +139,13 @@ export class HttpHealthCheckStrategy
196
139
  ): HttpAggregatedMetadata {
197
140
  const statusCodeCounts: Record<string, number> = {};
198
141
  let errorCount = 0;
142
+ let successCount = 0;
199
143
 
200
144
  for (const run of runs) {
201
145
  if (run.metadata?.error) {
202
146
  errorCount++;
147
+ } else {
148
+ successCount++;
203
149
  }
204
150
 
205
151
  if (run.metadata?.statusCode !== undefined) {
@@ -207,307 +153,66 @@ export class HttpHealthCheckStrategy
207
153
  statusCodeCounts[key] = (statusCodeCounts[key] || 0) + 1;
208
154
  }
209
155
  }
156
+ const successRate =
157
+ runs.length > 0 ? Math.round((successCount / runs.length) * 100) : 0;
210
158
 
211
- return { statusCodeCounts, errorCount };
159
+ return { statusCodeCounts, successRate, errorCount };
212
160
  }
213
161
 
214
- async execute(
162
+ /**
163
+ * Create an HTTP transport client for one-shot requests.
164
+ * All request parameters come from the collector (RequestCollector).
165
+ */
166
+ async createClient(
215
167
  config: HttpHealthCheckConfig
216
- ): Promise<HealthCheckResult<HttpResultMetadata>> {
217
- // Validate and apply defaults from schema
168
+ ): Promise<ConnectedClient<HttpTransportClient>> {
218
169
  const validatedConfig = this.config.validate(config);
219
170
 
220
- const start = performance.now();
221
- try {
222
- // Convert headers array to Record for fetch API
223
- const headersRecord: Record<string, string> = {};
224
- if (validatedConfig.headers) {
225
- for (const header of validatedConfig.headers) {
226
- headersRecord[header.name] = header.value;
227
- }
228
- }
229
-
230
- const response = await fetch(validatedConfig.url, {
231
- method: validatedConfig.method,
232
- headers: headersRecord,
233
- body: validatedConfig.body,
234
- signal: AbortSignal.timeout(validatedConfig.timeout),
235
- });
236
- const end = performance.now();
237
- const latencyMs = Math.round(end - start);
238
-
239
- // Collect response data for assertions
240
- const statusCode = response.status;
241
- const contentType = response.headers.get("content-type") || "";
242
-
243
- // Collect response headers for header assertions
244
- // Note: We get headers directly in the assertion loop, not pre-collected
245
-
246
- // Build values object for standard assertions
247
- const assertionValues: Record<string, unknown> = {
248
- statusCode,
249
- responseTime: latencyMs,
250
- contentType,
251
- };
252
-
253
- // Separate assertions by type
254
- const standardAssertions: Array<{
255
- field: string;
256
- operator: string;
257
- value?: unknown;
258
- }> = [];
259
- const headerAssertions: Array<z.infer<typeof headerAssertionSchema>> = [];
260
- const jsonPathAssertions: Array<z.infer<typeof jsonPathAssertionSchema>> =
261
- [];
262
-
263
- for (const assertion of validatedConfig.assertions || []) {
264
- if (assertion.field === "header") {
265
- headerAssertions.push(
266
- assertion as z.infer<typeof headerAssertionSchema>
267
- );
268
- } else if (assertion.field === "jsonPath") {
269
- jsonPathAssertions.push(
270
- assertion as z.infer<typeof jsonPathAssertionSchema>
271
- );
272
- } else {
273
- standardAssertions.push(assertion);
274
- }
275
- }
276
-
277
- // Evaluate standard assertions (statusCode, responseTime, contentType)
278
- const failedStandard = evaluateAssertions(
279
- standardAssertions,
280
- assertionValues
281
- );
282
- if (failedStandard) {
283
- return {
284
- status: "unhealthy",
285
- latencyMs,
286
- message: `Assertion failed: ${failedStandard.field} ${
287
- failedStandard.operator
288
- } ${"value" in failedStandard ? failedStandard.value : ""}`,
289
- metadata: {
290
- statusCode,
291
- contentType,
292
- failedAssertion: failedStandard as HttpAssertion,
293
- },
294
- };
295
- }
296
-
297
- // Evaluate header assertions
298
- for (const headerAssertion of headerAssertions) {
299
- // Get header value directly from the response
300
- const headerValue =
301
- response.headers.get(headerAssertion.headerName) ?? undefined;
302
- const passed = this.evaluateHeaderAssertion(
303
- headerAssertion.operator,
304
- headerValue,
305
- headerAssertion.value
171
+ const client: HttpTransportClient = {
172
+ async exec(request: HttpRequest): Promise<HttpResponse> {
173
+ const controller = new AbortController();
174
+ const timeoutId = setTimeout(
175
+ () => controller.abort(),
176
+ request.timeout ?? validatedConfig.timeout
306
177
  );
307
178
 
308
- if (!passed) {
309
- return {
310
- status: "unhealthy",
311
- latencyMs,
312
- message: `Header assertion failed: ${headerAssertion.headerName} ${
313
- headerAssertion.operator
314
- } ${headerAssertion.value || ""}. Actual: ${
315
- headerValue ?? "(missing)"
316
- }`,
317
- metadata: {
318
- statusCode,
319
- contentType,
320
- failedAssertion: headerAssertion,
321
- },
322
- };
323
- }
324
- }
325
-
326
- // Evaluate JSONPath assertions (only if present)
327
- if (jsonPathAssertions.length > 0) {
328
- let responseData: unknown;
329
-
330
179
  try {
331
- if (contentType.includes("application/json")) {
332
- responseData = await response.json();
333
- } else {
334
- const text = await response.text();
335
- try {
336
- responseData = JSON.parse(text);
337
- } catch {
338
- return {
339
- status: "unhealthy",
340
- latencyMs,
341
- message:
342
- "Response is not valid JSON, but JSONPath assertions are configured",
343
- metadata: { statusCode, contentType },
344
- };
345
- }
346
- }
347
- } catch (error_: unknown) {
348
- return {
349
- status: "unhealthy",
350
- latencyMs,
351
- message: `Failed to parse response body: ${
352
- (error_ as Error).message
353
- }`,
354
- metadata: { statusCode },
355
- };
356
- }
357
-
358
- const extractPath = (path: string, json: unknown) =>
359
- JSONPath({ path, json: json as object, wrap: false });
360
-
361
- for (const jsonPathAssertion of jsonPathAssertions) {
362
- const actualValue = extractPath(jsonPathAssertion.path, responseData);
363
- const passed = this.evaluateJsonPathAssertion(
364
- jsonPathAssertion.operator,
365
- actualValue,
366
- jsonPathAssertion.value
367
- );
368
-
369
- if (!passed) {
370
- return {
371
- status: "unhealthy",
372
- latencyMs,
373
- message: `JSONPath assertion failed: [${
374
- jsonPathAssertion.path
375
- }] ${jsonPathAssertion.operator} ${
376
- jsonPathAssertion.value || ""
377
- }. Actual: ${JSON.stringify(actualValue)}`,
378
- metadata: {
379
- statusCode,
380
- contentType,
381
- failedAssertion: jsonPathAssertion,
382
- },
383
- };
384
- }
385
- }
386
- }
387
-
388
- const assertionCount = validatedConfig.assertions?.length || 0;
389
- return {
390
- status: "healthy",
391
- latencyMs,
392
- message: `HTTP ${statusCode}${
393
- assertionCount > 0 ? ` - passed ${assertionCount} assertion(s)` : ""
394
- }`,
395
- metadata: { statusCode, contentType },
396
- };
397
- } catch (error: unknown) {
398
- const end = performance.now();
399
- const isError = error instanceof Error;
400
- return {
401
- status: "unhealthy",
402
- latencyMs: Math.round(end - start),
403
- message: isError ? error.message : "Request failed",
404
- metadata: { error: isError ? error.name : "UnknownError" },
405
- };
406
- }
407
- }
408
-
409
- private evaluateHeaderAssertion(
410
- operator: string,
411
- actual: string | undefined,
412
- expected = ""
413
- ): boolean {
414
- if (operator === "exists") return actual !== undefined;
415
-
416
- if (actual === undefined) return false;
417
-
418
- switch (operator) {
419
- case "equals": {
420
- return actual === expected;
421
- }
422
- case "notEquals": {
423
- return actual !== expected;
424
- }
425
- case "contains": {
426
- return actual.includes(expected);
427
- }
428
- case "startsWith": {
429
- return actual.startsWith(expected);
430
- }
431
- case "endsWith": {
432
- return actual.endsWith(expected);
433
- }
434
- default: {
435
- return false;
436
- }
437
- }
438
- }
439
-
440
- private evaluateJsonPathAssertion(
441
- operator: string,
442
- actual: unknown,
443
- expected: string | undefined
444
- ): boolean {
445
- // Existence checks
180
+ const response = await fetch(request.url, {
181
+ method: request.method,
182
+ headers: request.headers,
183
+ body: request.body,
184
+ signal: controller.signal,
185
+ });
446
186
 
447
- if (operator === "exists") return actual !== undefined && actual !== null;
187
+ clearTimeout(timeoutId);
448
188
 
449
- if (operator === "notExists")
450
- return actual === undefined || actual === null;
189
+ const body = await response.text();
190
+ const headers: Record<string, string> = {};
451
191
 
452
- // Numeric operators
453
- if (
454
- [
455
- "lessThan",
456
- "lessThanOrEqual",
457
- "greaterThan",
458
- "greaterThanOrEqual",
459
- ].includes(operator)
460
- ) {
461
- const numActual = Number(actual);
462
- const numExpected = Number(expected);
463
- if (Number.isNaN(numActual) || Number.isNaN(numExpected)) return false;
192
+ // eslint-disable-next-line unicorn/no-array-for-each
193
+ response.headers.forEach((value, key) => {
194
+ headers[key.toLowerCase()] = value;
195
+ });
464
196
 
465
- switch (operator) {
466
- case "lessThan": {
467
- return numActual < numExpected;
468
- }
469
- case "lessThanOrEqual": {
470
- return numActual <= numExpected;
471
- }
472
- case "greaterThan": {
473
- return numActual > numExpected;
474
- }
475
- case "greaterThanOrEqual": {
476
- return numActual >= numExpected;
477
- }
478
- }
479
- }
480
-
481
- // String operators
482
- const strActual = String(actual ?? "");
483
- const strExpected = expected || "";
484
-
485
- switch (operator) {
486
- case "equals": {
487
- return actual === expected || strActual === strExpected;
488
- }
489
- case "notEquals": {
490
- return actual !== expected && strActual !== strExpected;
491
- }
492
- case "contains": {
493
- return strActual.includes(strExpected);
494
- }
495
- case "startsWith": {
496
- return strActual.startsWith(strExpected);
497
- }
498
- case "endsWith": {
499
- return strActual.endsWith(strExpected);
500
- }
501
- case "matches": {
502
- try {
503
- return new RegExp(strExpected).test(strActual);
504
- } catch {
505
- return false;
197
+ return {
198
+ statusCode: response.status,
199
+ statusText: response.statusText,
200
+ headers,
201
+ body,
202
+ contentType: headers["content-type"],
203
+ };
204
+ } catch (error) {
205
+ clearTimeout(timeoutId);
206
+ throw error;
506
207
  }
507
- }
508
- default: {
509
- return false;
510
- }
511
- }
208
+ },
209
+ };
210
+
211
+ return {
212
+ client,
213
+ close: () => {
214
+ // HTTP is stateless, nothing to close
215
+ },
216
+ };
512
217
  }
513
218
  }
@@ -0,0 +1,29 @@
1
+ import type { TransportClient } from "@checkstack/common";
2
+
3
+ /**
4
+ * HTTP request configuration.
5
+ */
6
+ export interface HttpRequest {
7
+ url: string;
8
+ method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
9
+ headers?: Record<string, string>;
10
+ body?: string;
11
+ timeout?: number;
12
+ }
13
+
14
+ /**
15
+ * HTTP response result.
16
+ */
17
+ export interface HttpResponse {
18
+ statusCode: number;
19
+ statusText: string;
20
+ headers: Record<string, string>;
21
+ body: string;
22
+ contentType?: string;
23
+ }
24
+
25
+ /**
26
+ * HTTP transport client for collector execution.
27
+ * Each exec() performs a fresh HTTP request.
28
+ */
29
+ export type HttpTransportClient = TransportClient<HttpRequest, HttpResponse>;