186 lines
6.9 KiB
TypeScript
186 lines
6.9 KiB
TypeScript
import { CliError, errorMessage } from "./errors.js";
|
|
import { success } from "./output.js";
|
|
import type { RequestOptions, RuntimeContext } from "./types.js";
|
|
|
|
function headers(ctx: RuntimeContext, requireAuth: boolean, requireProject: boolean): Record<string, string> {
|
|
const out: Record<string, string> = {
|
|
Accept: "application/json, text/plain, */*",
|
|
"X-Request-Id": ctx.requestId,
|
|
...ctx.auth.headers,
|
|
};
|
|
if (requireAuth) {
|
|
if (!ctx.auth.accessToken) {
|
|
throw new CliError("认证失败", "UNAUTHENTICATED", "missing access token for agent context", 3, false, null, ["provide access_token via --auth-stdin or TJWATER_ACCESS_TOKEN env var"]);
|
|
}
|
|
out.Authorization = `Bearer ${ctx.auth.accessToken}`;
|
|
} else if (ctx.auth.accessToken) out.Authorization = `Bearer ${ctx.auth.accessToken}`;
|
|
if (requireProject) {
|
|
if (!ctx.auth.projectId) throw new CliError("认证失败", "PROJECT_CONTEXT_REQUIRED", "missing project_id for agent context", 3, false, null, ["add project_id to auth context"]);
|
|
out["X-Project-Id"] = ctx.auth.projectId;
|
|
} else if (ctx.auth.projectId) out["X-Project-Id"] = ctx.auth.projectId;
|
|
return out;
|
|
}
|
|
|
|
function stringifyParam(value: unknown): string {
|
|
if (typeof value === "boolean") return value ? "True" : "False";
|
|
return String(value);
|
|
}
|
|
|
|
function appendParams(url: URL, params: Record<string, unknown> = {}): void {
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined || value === null) continue;
|
|
if (Array.isArray(value)) value.forEach((item) => url.searchParams.append(key, stringifyParam(item)));
|
|
else url.searchParams.set(key, stringifyParam(value));
|
|
}
|
|
}
|
|
|
|
export async function requestJson(ctx: RuntimeContext, request: RequestOptions): Promise<[unknown, number]> {
|
|
const { method, path, params, body, requireAuth = true, requireProject = false } = request;
|
|
const url = new URL(`/api/v1${path}`, ctx.server.replace(/\/+$/, ""));
|
|
appendParams(url, params);
|
|
const started = performance.now();
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), ctx.timeout * 1000);
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method: method.toUpperCase(),
|
|
headers: {
|
|
...headers(ctx, requireAuth, requireProject),
|
|
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
signal: controller.signal,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") throw new CliError("请求超时", "REQUEST_TIMEOUT", `request timed out after ${ctx.timeout} seconds`, 7, true);
|
|
throw new CliError("连接失败", "REQUEST_FAILED", errorMessage(error), 7, true);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
const durationMs = Math.trunc(performance.now() - started);
|
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
const text = response.status === 204 ? "" : await response.text();
|
|
let payload: unknown = {};
|
|
if (contentType.includes("application/json") && text) payload = JSON.parse(text);
|
|
else if (text) payload = { report: text };
|
|
if (!response.ok) {
|
|
const record = payload && typeof payload === "object" && !Array.isArray(payload) ? (payload as Record<string, unknown>) : {};
|
|
const message = typeof record.detail === "string" ? record.detail : typeof record.message === "string" ? record.message : text || `http ${response.status}`;
|
|
throw new CliError("请求失败", `HTTP_${response.status}`, message, mapStatus(response.status), response.status >= 500);
|
|
}
|
|
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
const record = payload as Record<string, unknown>;
|
|
if (record.status === "error") throw new CliError("服务端错误", "SERVER_ERROR", String(record.message || "server returned error status"), 7, false, payload);
|
|
}
|
|
return [payload, durationMs];
|
|
}
|
|
|
|
export async function requestAllPages(
|
|
ctx: RuntimeContext,
|
|
request: RequestOptions,
|
|
pageSize: number,
|
|
): Promise<[unknown[], number]> {
|
|
const items: unknown[] = [];
|
|
let durationMs = 0;
|
|
let offset = 0;
|
|
let expectedTotal: number | null = null;
|
|
|
|
while (expectedTotal === null || offset < expectedTotal) {
|
|
const [payload, pageDurationMs] = await requestJson(ctx, {
|
|
...request,
|
|
params: {
|
|
...request.params,
|
|
limit: pageSize,
|
|
offset,
|
|
},
|
|
});
|
|
durationMs += pageDurationMs;
|
|
const page = normalizePage(payload);
|
|
if (!page) {
|
|
throw new CliError(
|
|
"服务端错误",
|
|
"INVALID_PAGINATION_RESPONSE",
|
|
"backend collection response must contain items, total, limit, and offset",
|
|
7,
|
|
false,
|
|
payload,
|
|
);
|
|
}
|
|
if (expectedTotal === null) {
|
|
expectedTotal = page.total;
|
|
} else if (page.total !== expectedTotal) {
|
|
throw new CliError(
|
|
"服务端错误",
|
|
"PAGINATION_TOTAL_CHANGED",
|
|
`backend collection total changed from ${expectedTotal} to ${page.total}`,
|
|
7,
|
|
true,
|
|
);
|
|
}
|
|
if (page.offset !== offset) {
|
|
throw new CliError(
|
|
"服务端错误",
|
|
"PAGINATION_OFFSET_MISMATCH",
|
|
`backend collection returned offset ${page.offset}, expected ${offset}`,
|
|
7,
|
|
true,
|
|
);
|
|
}
|
|
if (page.items.length === 0 && offset < expectedTotal) {
|
|
throw new CliError(
|
|
"服务端错误",
|
|
"PAGINATION_STALLED",
|
|
`backend collection returned an empty page at offset ${offset} before total ${expectedTotal}`,
|
|
7,
|
|
true,
|
|
);
|
|
}
|
|
items.push(...page.items);
|
|
offset += page.items.length;
|
|
}
|
|
|
|
return [items.slice(0, expectedTotal ?? 0), durationMs];
|
|
}
|
|
|
|
function normalizePage(
|
|
payload: unknown,
|
|
): { items: unknown[]; limit: number; offset: number; total: number } | null {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
const page = payload as Record<string, unknown>;
|
|
if (
|
|
!Array.isArray(page.items) ||
|
|
typeof page.limit !== "number" ||
|
|
!Number.isInteger(page.limit) ||
|
|
typeof page.offset !== "number" ||
|
|
!Number.isInteger(page.offset) ||
|
|
typeof page.total !== "number" ||
|
|
!Number.isInteger(page.total) ||
|
|
page.limit <= 0 ||
|
|
page.offset < 0 ||
|
|
page.total < 0
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
items: page.items,
|
|
limit: page.limit,
|
|
offset: page.offset,
|
|
total: page.total,
|
|
};
|
|
}
|
|
|
|
function mapStatus(status: number): number {
|
|
if (status === 400 || status === 422) return 2;
|
|
if (status === 401) return 3;
|
|
if (status === 403) return 4;
|
|
if (status === 404) return 5;
|
|
if (status === 409 || status === 412) return 6;
|
|
return 7;
|
|
}
|
|
|
|
export async function emitApi(ctx: RuntimeContext, summary: string, request: RequestOptions, nextCommands: string[] = []): Promise<void> {
|
|
const [data, durationMs] = await requestJson(ctx, request);
|
|
success(summary, data, ctx, durationMs, nextCommands);
|
|
}
|