Files
jerapah-flow/packages/server/scripts/fetch-http.js
nsrb 43e9370638 feat(scripts): enhance script return structure and context handling
- Introduced a new contract for script returns, requiring an object with `output`, `context`, and `skipRemaining` fields.
- Refactored existing scripts to align with the new return structure, ensuring compatibility with the updated context management.
- Added utility functions for normalizing context and step results, improving the handling of script execution context.
- Updated various scripts (e.g., fetch-binary, fetch-html, fetch-http) to return structured output and context, enhancing data flow and usability.
- Improved error handling for script returns, ensuring clearer feedback when invalid structures are returned.

This update significantly enhances the flexibility and clarity of script interactions within workflows.
2026-08-15 22:32:42 +07:00

173 lines
4.9 KiB
JavaScript

const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function isPlainObject(value) {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function resolveMethod(ctx) {
const raw = ctx.config?.method ?? ctx.data?.method ?? "GET";
if (typeof raw !== "string" || raw.length === 0) {
throw new Error("fetch-http: method must be a non-empty string");
}
const method = raw.toUpperCase();
if (!HTTP_METHODS.includes(method)) {
throw new Error(`fetch-http: unsupported method "${raw}"`);
}
return method;
}
function normalizeHeaders(value, label) {
if (value == null) return {};
if (!isPlainObject(value)) {
throw new Error(`fetch-http: ${label} must be an object`);
}
/** @type {Record<string, unknown>} */
const headers = {};
for (const [key, val] of Object.entries(value)) {
if (val == null) continue;
if (typeof val === "string") {
headers[key] = val;
continue;
}
if (typeof val === "number" || typeof val === "boolean") {
headers[key] = String(val);
continue;
}
if (typeof val === "object") {
headers[key] = val;
continue;
}
throw new Error(`fetch-http: header "${key}" must be a string`);
}
return headers;
}
function resolveHeaders(ctx) {
return {
...normalizeHeaders(ctx.config?.headers, "config.headers"),
...normalizeHeaders(ctx.data?.headers, "data.headers"),
};
}
function resolveBody(ctx) {
if (ctx.config != null && typeof ctx.config === "object" && "body" in ctx.config) {
return { present: true, value: ctx.config.body };
}
if (ctx.data != null && typeof ctx.data === "object" && "body" in ctx.data) {
return { present: true, value: ctx.data.body };
}
return { present: false, value: undefined };
}
function responseSize(data) {
if (data == null) return 0;
if (typeof data === "string") return data.length;
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) return data.length;
try {
return JSON.stringify(data).length;
} catch {
return null;
}
}
async function fetchHttp(ctx) {
const url = ctx.config?.url;
if (typeof url !== "string" || url.length === 0) {
throw new Error("fetch-http: ctx.config.url is required");
}
const method = resolveMethod(ctx);
const headers = resolveHeaders(ctx);
const body = resolveBody(ctx);
/** @type {Record<string, unknown>} */
const request = { url, method, headers };
if (body.present && method !== "HEAD") {
request.data = body.value;
}
log.info({ url, method }, "fetch-http: fetching");
const response = await $axios.request(request);
log.info(
{ status: response.status, length: responseSize(response.data) },
"fetch-http: fetch complete",
);
const output = {
httpResponse: response.data,
httpStatus: response.status,
};
log.info(
{ status: output.httpStatus, length: responseSize(output.httpResponse) },
"fetch-http: saved httpResponse",
);
return { output, context: { ...passContext(ctx), ...output } };
}
fetchHttp.meta = {
description: "Fetch a URL and store the response body.",
previewConfigKey: "url",
tags: ["HTTP"],
config: {
url: { type: "string", required: true, description: "Request URL" },
method: {
type: "string",
default: "GET",
enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
description: "HTTP method",
},
headers: {
type: "object",
required: false,
description: "Request headers (string values; Secret values are unwrapped)",
},
body: {
type: "any",
required: false,
description: "Request body for POST/PUT/PATCH (object, string, or buffer)",
},
},
input: {
method: {
type: "string",
required: false,
enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
description: "Fallback when config.method is omitted",
},
headers: {
type: "object",
required: false,
description: "Merged over config.headers (e.g. Authorization from a secret)",
},
body: { type: "any", required: false, description: "Fallback when config.body is omitted" },
},
output: {
httpResponse: { type: "any", description: "Response body as returned by the server" },
httpStatus: { type: "number", description: "HTTP status code" },
},
context: {
httpResponse: { type: "any", description: "Same as output.httpResponse" },
httpStatus: { type: "number", description: "Same as output.httpStatus" },
},
example: {
data: {},
config: {
url: "https://httpbin.org/post",
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: { hello: "world" },
},
},
};
export default fetchHttp;