Merge pull request #3736 from bluewave-labs/feat/head-request-be
feat: head request back end
This commit is contained in:
7 files changed
+164
-4
No files matched your search
@@ -3,6 +3,7 @@ import { booleanCoercion, dnsHostnameRegex, dnsServerValidation } from "./shared
|
||||
import { GeoContinents } from "@/domain/geo-checks/geo-check.type.js";
|
||||
import {
|
||||
DnsRecordTypes,
|
||||
HttpMethods,
|
||||
HttpStatusCodeSet,
|
||||
MonitorMatchMethods,
|
||||
MonitorStatuses,
|
||||
@@ -70,6 +71,16 @@ const refineStrategyType = (body: { type?: string; strategy?: string }, ctx: z.R
|
||||
}
|
||||
};
|
||||
|
||||
const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boolean; jsonPath?: string }, ctx: z.RefinementCtx) => {
|
||||
if (body.method === "HEAD" && (body.useAdvancedMatching === true || (body.jsonPath ?? "") !== "")) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["method"],
|
||||
message: "HEAD requests have no response body, so they cannot use advanced matching or a JSON path",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createMonitorBodyValidation = z
|
||||
.object({
|
||||
_id: z.string().optional(),
|
||||
@@ -95,6 +106,7 @@ export const createMonitorBodyValidation = z
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
method: z.enum(HttpMethods).optional(),
|
||||
gameId: z.union([z.string(), z.literal("")]).optional(),
|
||||
grpcServiceName: z.union([z.string(), z.literal("")]).default(""),
|
||||
strategy: z.enum(PageSpeedStrategies).optional(),
|
||||
@@ -107,7 +119,8 @@ export const createMonitorBodyValidation = z
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
})
|
||||
.superRefine(refineDnsHostname)
|
||||
.superRefine(refineStrategyType);
|
||||
.superRefine(refineStrategyType)
|
||||
.superRefine(refineHeadMatching);
|
||||
|
||||
export const editMonitorBodyValidation = z
|
||||
.object({
|
||||
@@ -127,6 +140,7 @@ export const editMonitorBodyValidation = z
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
method: z.enum(HttpMethods).optional(),
|
||||
port: z.number().min(1).max(65535).optional(),
|
||||
cpuAlertThreshold: z.number().optional(),
|
||||
memoryAlertThreshold: z.number().optional(),
|
||||
@@ -144,7 +158,8 @@ export const editMonitorBodyValidation = z
|
||||
dnsRecordType: z.enum(DnsRecordTypes).optional(),
|
||||
})
|
||||
.superRefine(refineDnsHostname)
|
||||
.superRefine(refineStrategyType);
|
||||
.superRefine(refineStrategyType)
|
||||
.superRefine(refineHeadMatching);
|
||||
|
||||
export const pauseMonitorParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
@@ -184,6 +199,7 @@ const importedMonitorSchema = z
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
|
||||
method: z.enum(HttpMethods).optional().default("GET"),
|
||||
url: z.string().min(1, "URL is required"),
|
||||
port: z.number().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
@@ -215,7 +231,8 @@ const importedMonitorSchema = z
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
.superRefine(refineDnsHostname)
|
||||
.superRefine(refineStrategyType);
|
||||
.superRefine(refineStrategyType)
|
||||
.superRefine(refineHeadMatching);
|
||||
|
||||
export const importMonitorsBodyValidation = z.object({
|
||||
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
|
||||
@@ -251,6 +268,7 @@ export const monitorResponseSchema = z
|
||||
jsonPath: z.string().optional(),
|
||||
expectedValue: z.string().optional(),
|
||||
matchMethod: z.enum(MonitorMatchMethods).optional(),
|
||||
method: z.enum(HttpMethods),
|
||||
notifications: z.array(z.string()),
|
||||
tags: z.array(z.string()),
|
||||
customUpCodes: z.array(httpStatusCode).optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema, model, Types } from "mongoose";
|
||||
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.types.js";
|
||||
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies } from "@/domain/monitors/monitor.types.js";
|
||||
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.types.js";
|
||||
import type {
|
||||
CheckAudits,
|
||||
CheckCaptureInfo,
|
||||
@@ -221,6 +221,12 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
description: {
|
||||
type: String,
|
||||
},
|
||||
method: {
|
||||
type: String,
|
||||
enum: HttpMethods,
|
||||
default: "GET",
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: MonitorStatuses,
|
||||
|
||||
@@ -536,6 +536,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
teamId: toStringId(doc.teamId),
|
||||
name: doc.name,
|
||||
description: doc.description ?? undefined,
|
||||
method: doc.method,
|
||||
status: doc.status ?? "initializing",
|
||||
statusWindow: doc.statusWindow ?? [],
|
||||
statusWindowSize: doc.statusWindowSize,
|
||||
@@ -602,6 +603,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
teamId: toStringId(doc.teamId),
|
||||
name: doc.name,
|
||||
description: doc.description ?? undefined,
|
||||
method: doc.method,
|
||||
status: doc.status ?? "initializing",
|
||||
statusWindow: doc.statusWindow ?? [],
|
||||
statusWindowSize: doc.statusWindowSize,
|
||||
|
||||
@@ -62,6 +62,9 @@ export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | "";
|
||||
export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const;
|
||||
export type DnsRecordType = (typeof DnsRecordTypes)[number];
|
||||
|
||||
export const HttpMethods = ["GET", "HEAD"] as const;
|
||||
export type HttpMethod = (typeof HttpMethods)[number];
|
||||
|
||||
export const MAX_RECENT_CHECKS = 50;
|
||||
|
||||
export interface Monitor {
|
||||
@@ -70,6 +73,7 @@ export interface Monitor {
|
||||
teamId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
method: HttpMethod;
|
||||
status: MonitorStatus;
|
||||
statusWindow: boolean[];
|
||||
statusWindowSize: number;
|
||||
|
||||
@@ -53,6 +53,22 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
}
|
||||
): MonitorStatusResponse<T> {
|
||||
const { body, contentType, statusCode, statusUp, message, responseTime, timings } = opts;
|
||||
|
||||
// Return early for HEAD requests, no body to parse
|
||||
if (monitor.method === "HEAD") {
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: statusUp,
|
||||
code: statusCode,
|
||||
message,
|
||||
responseTime,
|
||||
timings,
|
||||
payload: body as T | string,
|
||||
};
|
||||
}
|
||||
|
||||
const isJson = contentType.includes("application/json");
|
||||
|
||||
if (monitor.jsonPath && !isJson) {
|
||||
@@ -155,6 +171,8 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
|
||||
};
|
||||
|
||||
options.method = monitor.method;
|
||||
|
||||
try {
|
||||
const response = await this.got<string>(url, options);
|
||||
const statusUp = isStatusUp(response.statusCode, monitor.customUpCodes);
|
||||
|
||||
@@ -433,4 +433,65 @@ describe("HttpProvider", () => {
|
||||
expect(result.extracted).toBe("down");
|
||||
});
|
||||
});
|
||||
|
||||
// ── HTTP method (GET / HEAD) ─────────────────────────────────────────
|
||||
|
||||
describe("request method", () => {
|
||||
it("passes GET to got when method is GET", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ method: "GET" }));
|
||||
|
||||
expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "GET" }));
|
||||
});
|
||||
|
||||
it("passes HEAD to got when method is HEAD", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ body: "" }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ method: "HEAD" }));
|
||||
|
||||
expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "HEAD" }));
|
||||
});
|
||||
|
||||
it("does not mark a HEAD monitor down on an empty body even with advanced matching on", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse({ body: "", headers: {} }));
|
||||
// A matcher that would fail — proving HEAD never reaches it
|
||||
const matcher = createMockMatcher({ ok: false, message: "Extracted value is falsy" });
|
||||
const { provider, advancedMatcher } = createProvider(matcher);
|
||||
|
||||
const result = await provider.handle(makeMonitor({ method: "HEAD", useAdvancedMatching: true, jsonPath: "status" }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
expect(advancedMatcher.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bases HEAD status on the status code (down when not an up code)", async () => {
|
||||
const err = new HTTPError("Internal Server Error");
|
||||
(err as any).response = { statusCode: 500, body: "", headers: {} };
|
||||
(err as any).timings = { phases: { total: 40 } };
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ method: "HEAD" }));
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(500);
|
||||
});
|
||||
|
||||
it("respects customUpCodes for HEAD requests without consulting the matcher", async () => {
|
||||
const err = new HTTPError("Unauthorized");
|
||||
(err as any).response = { statusCode: 401, body: "", headers: {} };
|
||||
(err as any).timings = { phases: { total: 30 } };
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider, advancedMatcher } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor({ method: "HEAD", customUpCodes: [401], useAdvancedMatching: true }));
|
||||
|
||||
expect(result.status).toBe(true);
|
||||
expect(result.code).toBe(401);
|
||||
expect(advancedMatcher.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -227,6 +227,57 @@ describe("monitorValidation — strategy gating", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitorValidation — HEAD method gating", () => {
|
||||
const baseHead = {
|
||||
name: "HEAD check",
|
||||
type: "http" as const,
|
||||
url: "https://example.com",
|
||||
method: "HEAD" as const,
|
||||
};
|
||||
|
||||
describe("createMonitorBodyValidation", () => {
|
||||
it("accepts a HEAD monitor with no body matching", () => {
|
||||
const parsed = createMonitorBodyValidation.parse(baseHead);
|
||||
expect(parsed.method).toBe("HEAD");
|
||||
});
|
||||
|
||||
it("accepts GET together with advanced matching", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({
|
||||
...baseHead,
|
||||
method: "GET",
|
||||
useAdvancedMatching: true,
|
||||
jsonPath: "status",
|
||||
});
|
||||
expect(parsed.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("rejects HEAD combined with advanced matching", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHead, useAdvancedMatching: true })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects HEAD combined with a non-empty jsonPath", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHead, jsonPath: "status" })).toThrow();
|
||||
});
|
||||
|
||||
it("allows HEAD with an empty-string jsonPath", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHead, jsonPath: "" });
|
||||
expect(parsed.method).toBe("HEAD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editMonitorBodyValidation", () => {
|
||||
it("rejects HEAD combined with advanced matching", () => {
|
||||
expect(() => editMonitorBodyValidation.parse({ method: "HEAD", useAdvancedMatching: true })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("importMonitorsBodyValidation", () => {
|
||||
it("rejects HEAD combined with a jsonPath on imported monitors", () => {
|
||||
expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHead, jsonPath: "status" }] })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitorValidation — customUpCodes", () => {
|
||||
const baseHttpBody = {
|
||||
name: "HTTP check",
|
||||
|
||||
Reference in new issue
Block a user