将变量替换和 expect 简写展开统一放入 Normalized 阶段, 运行时 AJV 使用 Normalized schema,导出 schema 面向 Authoring Config。 主要变更: - 新增 normalizer.ts 实现 normalizeAuthoringConfig() - 拆分 Authoring/Normalized 双 schema,checker 接口支持 authoring/normalized 片段 - config-loader 流程:normalize → Normalized AJV → semantic → resolve - validator 兼容层自动分派 raw/normalized expect 形态 - 删除 rawExpect,store.expect 列写入 null - Authoring schema 对 integer/boolean/enum 字段接受变量引用 - 修复 DB/HTTP validate 入口守卫和 LLM options integer 变量引用 - 优化 compact() 避免 undefined 覆盖隐患 - 移除 content.ts 恒为 true 的前置条件 - 同步 5 个主规范并归档 change
441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
import { isError } from "es-toolkit";
|
|
|
|
import type { CheckResult, RawTargetConfig } from "../../types";
|
|
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
|
|
import type { HttpTargetConfig, ResolvedHttpExpectConfig, ResolvedHttpTarget } from "./types";
|
|
|
|
import { checkContentExpectations } from "../../expect/content";
|
|
import { errorFailure, mismatchFailure } from "../../expect/failure";
|
|
import { checkHeaderExpectations } from "../../expect/headers";
|
|
import { checkStatusCode } from "../../expect/status";
|
|
import { checkValueExpectation, displayValueExpectation } from "../../expect/value";
|
|
import { parseSize } from "../../utils";
|
|
import { httpCheckerSchemas } from "./schema";
|
|
import { validateHttpConfig } from "./validate";
|
|
|
|
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
|
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
|
|
|
|
export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
|
readonly configKey = "http";
|
|
|
|
readonly schemas = httpCheckerSchemas;
|
|
|
|
readonly type = "http";
|
|
|
|
buildDetail(observation: Record<string, unknown>): null | string {
|
|
const statusCode = observation["statusCode"];
|
|
return typeof statusCode === "number" ? `HTTP ${statusCode}` : null;
|
|
}
|
|
|
|
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
|
|
const timestamp = new Date().toISOString();
|
|
const expect = t.expect;
|
|
const start = performance.now();
|
|
|
|
try {
|
|
const response = await fetchWithRedirects(t.http.url, t.http.maxRedirects, {
|
|
body: t.http.method !== "GET" && t.http.method !== "HEAD" ? t.http.body : undefined,
|
|
headers: t.http.headers,
|
|
method: t.http.method,
|
|
redirect: "manual",
|
|
signal: ctx.signal,
|
|
...(t.http.ignoreSSL ? { tls: { rejectUnauthorized: false } } : {}),
|
|
});
|
|
|
|
const statusCode = response.status;
|
|
const responseHeaders = truncateHeaders(Object.fromEntries(response.headers));
|
|
let bodyPreview: null | string = null;
|
|
let bodyText: null | string = null;
|
|
const hasBodyExpectations = !!(expect?.body && expect.body.length > 0);
|
|
|
|
const statusResult = checkStatusCode(statusCode, expect?.status ?? [200]);
|
|
if (!statusResult.matched) {
|
|
return makeResult(
|
|
t,
|
|
timestamp,
|
|
performance.now() - start,
|
|
statusResult.failure,
|
|
response,
|
|
responseHeaders,
|
|
bodyPreview,
|
|
);
|
|
}
|
|
|
|
const headersResult = checkHeaderExpectations(Object.fromEntries(response.headers), expect?.headers);
|
|
if (!headersResult.matched) {
|
|
return makeResult(
|
|
t,
|
|
timestamp,
|
|
performance.now() - start,
|
|
headersResult.failure,
|
|
response,
|
|
responseHeaders,
|
|
bodyPreview,
|
|
);
|
|
}
|
|
|
|
const earlyTimeout = hasBodyExpectations ? checkEarlyTimeout(start, expect?.durationMs) : null;
|
|
if (earlyTimeout) {
|
|
return makeResult(
|
|
t,
|
|
timestamp,
|
|
earlyTimeout.elapsed,
|
|
earlyTimeout.failure,
|
|
response,
|
|
responseHeaders,
|
|
bodyPreview,
|
|
);
|
|
}
|
|
|
|
if (hasBodyExpectations) {
|
|
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
|
|
let bodyDecodeFailure: CheckResult["failure"] = null;
|
|
|
|
if (bodyReadResult.data.byteLength > 0) {
|
|
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
|
|
if (decodeResult.ok) {
|
|
bodyText = decodeResult.text;
|
|
bodyPreview = truncateBodyPreview(decodeResult.text);
|
|
} else {
|
|
bodyDecodeFailure = decodeResult.failure;
|
|
}
|
|
}
|
|
|
|
if (!bodyReadResult.ok) {
|
|
return makeResult(
|
|
t,
|
|
timestamp,
|
|
performance.now() - start,
|
|
bodyReadResult.failure,
|
|
response,
|
|
responseHeaders,
|
|
bodyPreview,
|
|
);
|
|
}
|
|
|
|
if (bodyDecodeFailure) {
|
|
return makeResult(
|
|
t,
|
|
timestamp,
|
|
performance.now() - start,
|
|
bodyDecodeFailure,
|
|
response,
|
|
responseHeaders,
|
|
bodyPreview,
|
|
);
|
|
}
|
|
|
|
const bodyResult = checkContentExpectations(bodyText ?? "", expect.body, { path: "body", phase: "body" });
|
|
if (!bodyResult.matched) {
|
|
return makeResult(
|
|
t,
|
|
timestamp,
|
|
performance.now() - start,
|
|
bodyResult.failure,
|
|
response,
|
|
responseHeaders,
|
|
bodyPreview,
|
|
);
|
|
}
|
|
}
|
|
|
|
const durationMs = Math.round(performance.now() - start);
|
|
const durationResult = checkValueExpectation(durationMs, expect?.durationMs, {
|
|
message: "durationMs mismatch",
|
|
path: "durationMs",
|
|
phase: "duration",
|
|
});
|
|
if (!durationResult.matched) {
|
|
return makeResult(t, timestamp, durationMs, durationResult.failure, response, responseHeaders, bodyPreview);
|
|
}
|
|
|
|
return makeResult(t, timestamp, durationMs, null, response, responseHeaders, bodyPreview);
|
|
} catch (error) {
|
|
const durationMs = Math.round(performance.now() - start);
|
|
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
|
|
|
|
return {
|
|
detail: null,
|
|
durationMs,
|
|
failure: errorFailure(
|
|
"request",
|
|
"request",
|
|
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
|
|
),
|
|
matched: false,
|
|
observation: null,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
}
|
|
|
|
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedHttpTarget {
|
|
const t = target as RawTargetConfig & { http: HttpTargetConfig; type: "http" };
|
|
|
|
const method = t.http.method ?? "GET";
|
|
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? "100MB");
|
|
|
|
const expect = target.expect as ResolvedHttpExpectConfig | undefined;
|
|
const resolvedExpect: ResolvedHttpExpectConfig = expect
|
|
? {
|
|
...expect,
|
|
status: expect.status ?? [200],
|
|
}
|
|
: { status: [200] };
|
|
|
|
return {
|
|
description: null,
|
|
expect: resolvedExpect,
|
|
group: target.group ?? "default",
|
|
http: {
|
|
body: t.http.body,
|
|
headers: { ...(t.http.headers ?? {}) },
|
|
ignoreSSL: t.http.ignoreSSL ?? false,
|
|
maxBodyBytes,
|
|
maxRedirects: t.http.maxRedirects ?? 0,
|
|
method,
|
|
url: t.http.url,
|
|
},
|
|
id: t.id,
|
|
intervalMs: context.defaultIntervalMs,
|
|
name: t.name ?? null,
|
|
timeoutMs: context.defaultTimeoutMs,
|
|
type: "http",
|
|
} satisfies ResolvedHttpTarget;
|
|
}
|
|
|
|
serialize(t: ResolvedHttpTarget): { config: string; target: string } {
|
|
return {
|
|
config: JSON.stringify({
|
|
body: t.http.body,
|
|
headers: t.http.headers,
|
|
ignoreSSL: t.http.ignoreSSL,
|
|
maxBodyBytes: t.http.maxBodyBytes,
|
|
maxRedirects: t.http.maxRedirects,
|
|
method: t.http.method,
|
|
url: t.http.url,
|
|
}),
|
|
target: t.http.url,
|
|
};
|
|
}
|
|
|
|
validate(input: CheckerValidationInput) {
|
|
return validateHttpConfig(input);
|
|
}
|
|
}
|
|
|
|
function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
|
|
const result = new Uint8Array(totalBytes);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
result.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
|
|
let newInit = { ...init };
|
|
const method = init.method?.toUpperCase();
|
|
|
|
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && method === "POST")) {
|
|
const headers =
|
|
typeof init.headers === "object" && init.headers !== null
|
|
? { ...(init.headers as Record<string, string>) }
|
|
: undefined;
|
|
if (headers) {
|
|
for (const key of Object.keys(headers)) {
|
|
const lower = key.toLowerCase();
|
|
if (lower === "content-type" || lower === "content-length") {
|
|
delete headers[key];
|
|
}
|
|
}
|
|
}
|
|
newInit = { ...newInit, body: undefined, headers, method: "GET" };
|
|
}
|
|
|
|
try {
|
|
const fromOrigin = new URL(fromUrl).origin;
|
|
const toOrigin = new URL(toUrl).origin;
|
|
if (fromOrigin !== toOrigin && typeof newInit.headers === "object" && newInit.headers !== null) {
|
|
const headers = { ...(newInit.headers as Record<string, string>) };
|
|
for (const key of Object.keys(headers)) {
|
|
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
|
|
delete headers[key];
|
|
}
|
|
}
|
|
newInit.headers = headers;
|
|
}
|
|
} catch {
|
|
/* URL parsing failed, keep headers */
|
|
}
|
|
|
|
return newInit;
|
|
}
|
|
|
|
function checkEarlyTimeout(
|
|
start: number,
|
|
durationMatcher: ResolvedHttpExpectConfig["durationMs"] | undefined,
|
|
): null | { elapsed: number; failure: CheckResult["failure"] } {
|
|
if (!durationMatcher) return null;
|
|
const elapsed = performance.now() - start;
|
|
const lteFailed = durationMatcher.lte !== undefined && elapsed > durationMatcher.lte;
|
|
const ltFailed = durationMatcher.lt !== undefined && elapsed >= durationMatcher.lt;
|
|
if (!lteFailed && !ltFailed) return null;
|
|
|
|
const durationMs = Math.round(elapsed);
|
|
const durationResult = checkValueExpectation(durationMs, durationMatcher, {
|
|
message: "durationMs mismatch",
|
|
path: "durationMs",
|
|
phase: "duration",
|
|
});
|
|
return {
|
|
elapsed,
|
|
failure:
|
|
durationResult.failure ??
|
|
mismatchFailure(
|
|
"duration",
|
|
"durationMs",
|
|
displayValueExpectation(durationMatcher),
|
|
durationMs,
|
|
"durationMs mismatch",
|
|
),
|
|
};
|
|
}
|
|
|
|
function decodeBody(
|
|
data: Uint8Array,
|
|
headers: Headers,
|
|
): { failure: CheckResult["failure"]; ok: false } | { ok: true; text: string } {
|
|
const contentType = headers.get("content-type") ?? "";
|
|
const charsetMatch = CHARSET_RE.exec(contentType);
|
|
const encoding = charsetMatch?.[1]?.toLowerCase() ?? "utf-8";
|
|
|
|
try {
|
|
const text = new TextDecoder(encoding).decode(data);
|
|
return { ok: true, text };
|
|
} catch {
|
|
return {
|
|
failure: errorFailure("body", "body", `不支持的字符编码: ${encoding}`),
|
|
ok: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function fetchWithRedirects(url: string, maxRedirects: number, init: RequestInit): Promise<Response> {
|
|
let currentUrl = url;
|
|
let currentInit = init;
|
|
|
|
for (let followed = 0; ; followed++) {
|
|
const response = await fetch(currentUrl, currentInit);
|
|
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
|
|
const location = response.headers.get("location");
|
|
if (!location || followed >= maxRedirects) return response;
|
|
|
|
try {
|
|
await response.arrayBuffer();
|
|
} catch {
|
|
/* ignore body drain error */
|
|
}
|
|
|
|
const nextUrl = new URL(location, currentUrl).toString();
|
|
currentInit = buildRedirectInit(currentInit, response.status, currentUrl, nextUrl);
|
|
currentUrl = nextUrl;
|
|
}
|
|
}
|
|
|
|
function makeResult(
|
|
t: ResolvedHttpTarget,
|
|
timestamp: string,
|
|
elapsed: number,
|
|
failure: CheckResult["failure"],
|
|
response: Response,
|
|
headers: Record<string, string>,
|
|
bodyPreview: null | string = null,
|
|
): CheckResult {
|
|
const contentType = response.headers.get("content-type");
|
|
const contentLengthHeader = response.headers.get("content-length");
|
|
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;
|
|
|
|
const observation: Record<string, unknown> = {
|
|
bodyPreview,
|
|
contentLength: Number.isFinite(contentLength) ? contentLength : null,
|
|
contentType,
|
|
headers,
|
|
statusCode: response.status,
|
|
};
|
|
|
|
return {
|
|
detail: null,
|
|
durationMs: Math.round(elapsed),
|
|
failure,
|
|
matched: failure === null,
|
|
observation,
|
|
targetId: t.id,
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
async function readBodyStream(
|
|
response: Response,
|
|
maxBodyBytes: number,
|
|
truncateOnLimit = false,
|
|
): Promise<{ data: Uint8Array; failure: CheckResult["failure"]; ok: false } | { data: Uint8Array; ok: true }> {
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
return { data: new Uint8Array(0), ok: true };
|
|
}
|
|
|
|
const chunks: Uint8Array[] = [];
|
|
let totalBytes = 0;
|
|
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
totalBytes += value.byteLength;
|
|
if (totalBytes > maxBodyBytes) {
|
|
const allowedBytes = value.byteLength - (totalBytes - maxBodyBytes);
|
|
if (truncateOnLimit && allowedBytes > 0) {
|
|
chunks.push(value.slice(0, allowedBytes));
|
|
}
|
|
try {
|
|
await reader.cancel();
|
|
} catch {
|
|
/* ignore cancel error */
|
|
}
|
|
const data = assembleChunks(chunks, Math.min(totalBytes, maxBodyBytes));
|
|
if (truncateOnLimit) return { data, ok: true };
|
|
return {
|
|
data,
|
|
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
|
|
ok: false,
|
|
};
|
|
}
|
|
|
|
chunks.push(value);
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
|
|
return { data: assembleChunks(chunks, totalBytes), ok: true };
|
|
}
|
|
|
|
function truncateBodyPreview(text: string, maxLen = 1024): string {
|
|
if (text.length <= maxLen) return text;
|
|
return text.slice(0, maxLen);
|
|
}
|
|
|
|
function truncateHeaders(headers: Record<string, string>, maxCount = 20): Record<string, string> {
|
|
const entries = Object.entries(headers);
|
|
if (entries.length <= maxCount) return headers;
|
|
return Object.fromEntries(entries.slice(0, maxCount));
|
|
}
|