@checkstack/healthcheck-http-backend 0.0.3 → 0.1.1

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,188 +1,115 @@
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
- statusCode: healthResultNumber({
135
- "x-chart-type": "pie",
136
- "x-chart-label": "Status Code",
137
- }).optional(),
138
- contentType: healthResultString({
139
- "x-chart-type": "counter",
140
- "x-chart-label": "Content Type",
141
- }).optional(),
142
- failedAssertion: httpAssertionSchema.optional(),
143
51
  error: healthResultString({
144
52
  "x-chart-type": "status",
145
53
  "x-chart-label": "Error",
146
54
  }).optional(),
147
55
  });
148
56
 
149
- export type HttpResultMetadata = z.infer<typeof httpResultMetadataSchema>;
57
+ type HttpResultMetadata = z.infer<typeof httpResultMetadataSchema>;
150
58
 
151
59
  /** Aggregated metadata for buckets */
152
60
  const httpAggregatedMetadataSchema = z.object({
153
- statusCodeCounts: z.record(z.string(), z.number()).meta({
154
- "x-chart-type": "pie",
155
- "x-chart-label": "Status Code Distribution",
156
- }),
157
61
  errorCount: healthResultNumber({
158
62
  "x-chart-type": "counter",
159
63
  "x-chart-label": "Errors",
160
64
  }),
161
65
  });
162
66
 
163
- export type HttpAggregatedMetadata = z.infer<
164
- typeof httpAggregatedMetadataSchema
165
- >;
67
+ type HttpAggregatedMetadata = z.infer<typeof httpAggregatedMetadataSchema>;
68
+
69
+ // ============================================================================
70
+ // STRATEGY
71
+ // ============================================================================
166
72
 
167
73
  export class HttpHealthCheckStrategy
168
74
  implements
169
75
  HealthCheckStrategy<
170
76
  HttpHealthCheckConfig,
77
+ HttpTransportClient,
171
78
  HttpResultMetadata,
172
79
  HttpAggregatedMetadata
173
80
  >
174
81
  {
175
82
  id = "http";
176
83
  displayName = "HTTP/HTTPS Health Check";
177
- description = "HTTP endpoint health monitoring with flexible assertions";
84
+ description = "HTTP endpoint health monitoring";
178
85
 
179
86
  config: Versioned<HttpHealthCheckConfig> = new Versioned({
180
- version: 2, // Bumped for breaking change
87
+ version: 3, // v3 for createClient pattern with action params moved to RequestCollector
181
88
  schema: httpHealthCheckConfigSchema,
89
+ migrations: [
90
+ {
91
+ fromVersion: 1,
92
+ toVersion: 2,
93
+ description: "Add timeout field",
94
+ migrate: (data: HttpConfigV1): HttpConfigV2 => ({
95
+ ...data,
96
+ timeout: 30_000,
97
+ }),
98
+ },
99
+ {
100
+ fromVersion: 2,
101
+ toVersion: 3,
102
+ description:
103
+ "Remove url/method/headers/body (moved to RequestCollector)",
104
+ migrate: (data: HttpConfigV2): HttpHealthCheckConfig => ({
105
+ timeout: data.timeout,
106
+ }),
107
+ },
108
+ ],
182
109
  });
183
110
 
184
111
  result: Versioned<HttpResultMetadata> = new Versioned({
185
- version: 2,
112
+ version: 3,
186
113
  schema: httpResultMetadataSchema,
187
114
  });
188
115
 
@@ -194,320 +121,71 @@ export class HttpHealthCheckStrategy
194
121
  aggregateResult(
195
122
  runs: HealthCheckRunForAggregation<HttpResultMetadata>[]
196
123
  ): HttpAggregatedMetadata {
197
- const statusCodeCounts: Record<string, number> = {};
198
124
  let errorCount = 0;
199
125
 
200
126
  for (const run of runs) {
201
127
  if (run.metadata?.error) {
202
128
  errorCount++;
203
129
  }
204
-
205
- if (run.metadata?.statusCode !== undefined) {
206
- const key = String(run.metadata.statusCode);
207
- statusCodeCounts[key] = (statusCodeCounts[key] || 0) + 1;
208
- }
209
130
  }
210
131
 
211
- return { statusCodeCounts, errorCount };
132
+ return { errorCount };
212
133
  }
213
134
 
214
- async execute(
135
+ /**
136
+ * Create an HTTP transport client for one-shot requests.
137
+ * All request parameters come from the collector (RequestCollector).
138
+ */
139
+ async createClient(
215
140
  config: HttpHealthCheckConfig
216
- ): Promise<HealthCheckResult<HttpResultMetadata>> {
217
- // Validate and apply defaults from schema
141
+ ): Promise<ConnectedClient<HttpTransportClient>> {
218
142
  const validatedConfig = this.config.validate(config);
219
143
 
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
144
+ const client: HttpTransportClient = {
145
+ async exec(request: HttpRequest): Promise<HttpResponse> {
146
+ const controller = new AbortController();
147
+ const timeoutId = setTimeout(
148
+ () => controller.abort(),
149
+ request.timeout ?? validatedConfig.timeout
306
150
  );
307
151
 
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
152
  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
- }
153
+ const response = await fetch(request.url, {
154
+ method: request.method,
155
+ headers: request.headers,
156
+ body: request.body,
157
+ signal: controller.signal,
158
+ });
357
159
 
358
- const extractPath = (path: string, json: unknown) =>
359
- JSONPath({ path, json: json as object, wrap: false });
160
+ clearTimeout(timeoutId);
360
161
 
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
- }
162
+ const body = await response.text();
163
+ const headers: Record<string, string> = {};
439
164
 
440
- private evaluateJsonPathAssertion(
441
- operator: string,
442
- actual: unknown,
443
- expected: string | undefined
444
- ): boolean {
445
- // Existence checks
165
+ // eslint-disable-next-line unicorn/no-array-for-each
166
+ response.headers.forEach((value, key) => {
167
+ headers[key.toLowerCase()] = value;
168
+ });
446
169
 
447
- if (operator === "exists") return actual !== undefined && actual !== null;
448
-
449
- if (operator === "notExists")
450
- return actual === undefined || actual === null;
451
-
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;
464
-
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;
170
+ return {
171
+ statusCode: response.status,
172
+ statusText: response.statusText,
173
+ headers,
174
+ body,
175
+ contentType: headers["content-type"],
176
+ };
177
+ } catch (error) {
178
+ clearTimeout(timeoutId);
179
+ throw error;
506
180
  }
507
- }
508
- default: {
509
- return false;
510
- }
511
- }
181
+ },
182
+ };
183
+
184
+ return {
185
+ client,
186
+ close: () => {
187
+ // HTTP is stateless, nothing to close
188
+ },
189
+ };
512
190
  }
513
191
  }
@@ -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>;