Files
brk/website_next/ask/tools/api/execute.js
T
2026-07-28 21:35:02 +02:00

128 lines
4.3 KiB
JavaScript

import { brk } from "../../../utils/client.js";
const MAX_TEXT = 2_000;
const MAX_ARRAY = 8;
const ARRAY_SAMPLE = 4;
const MAX_KEYS = 64;
const MAX_DEPTH = 6;
/** @param {unknown} value */
function hasValue(value) {
return value !== undefined && value !== null && value !== "";
}
/** @param {unknown} value @param {number} depth @param {{ truncated: boolean }} state @returns {unknown} */
function compact(value, depth, state) {
if (depth >= MAX_DEPTH) {
state.truncated = true;
return "[nested value omitted]";
}
if (typeof value === "string" && value.length > MAX_TEXT) {
state.truncated = true;
return `${value.slice(0, MAX_TEXT)}`;
}
if (Array.isArray(value)) {
if (value.length <= MAX_ARRAY) {
return value.map((item) => compact(item, depth + 1, state));
}
state.truncated = true;
return {
count: value.length,
sample: value.slice(0, ARRAY_SAMPLE).map((item) => compact(item, depth + 1, state)),
};
}
if (value && typeof value === "object") {
const entries = Object.entries(value);
if (entries.length > MAX_KEYS) state.truncated = true;
return Object.fromEntries(
entries.slice(0, MAX_KEYS).map(([key, item]) => [
key,
compact(item, depth + 1, state),
]),
);
}
return value;
}
/** @param {unknown} value @param {import("./index.js").ApiParameter} parameter */
function parameterValue(value, parameter) {
const type = parameter.primitive ?? parameter.valueType ?? parameter.type;
if (type.includes("integer")) {
const number = Number(value);
if (!Number.isInteger(number)) throw new Error(`${parameter.name} must be an integer`);
return String(number);
}
if (type.includes("number")) {
const number = Number(value);
if (!Number.isFinite(number)) throw new Error(`${parameter.name} must be a number`);
return String(number);
}
if (type.includes("boolean")) {
if (value !== true && value !== false && value !== "true" && value !== "false") {
throw new Error(`${parameter.name} must be true or false`);
}
return String(value);
}
const string = Array.isArray(value) ? value.join(",") : String(value);
if (parameter.enum?.length && !parameter.enum.map(String).includes(string)) {
throw new Error(`${parameter.name} must be one of: ${parameter.enum.join(", ")}`);
}
return string;
}
/**
* @param {import("./index.js").ApiOperation} operation
* @param {Record<string, unknown>} arguments_
* @param {AbortSignal} signal
*/
export async function executeApi(operation, arguments_, signal) {
if (operation.method !== "GET" || !operation.path.startsWith("/")) {
throw new Error("Only generated read-only API operations are allowed");
}
const allowed = new Set(operation.parameters.map((parameter) => parameter.name));
for (const key of Object.keys(arguments_)) {
if (!allowed.has(key)) throw new Error(`Unexpected API parameter: ${key}`);
}
let path = operation.path;
const query = new URLSearchParams();
for (const parameter of operation.parameters) {
const value = arguments_[parameter.name];
if (!hasValue(value)) {
if (parameter.required) throw new Error(`${parameter.name} is required`);
continue;
}
const encoded = parameterValue(value, parameter);
if (parameter.in === "path") {
path = path.replace(`{${parameter.name}}`, encodeURIComponent(encoded));
} else if (parameter.in === "query") {
query.set(parameter.name, encoded);
}
}
if (/\{[^}]+\}/.test(path)) throw new Error("A required path parameter is missing");
if (query.size) path += `?${query}`;
const response = await brk.get(path, { signal });
const contentType = response.headers.get("content-type") ?? operation.response.contentType;
const raw = contentType.includes("json") ? await response.json() : await response.text();
const state = { truncated: false };
const data = compact(raw, 0, state);
return {
operation: {
key: operation.key,
method: operation.method,
path: operation.path,
summary: operation.summary || operation.label,
description: operation.description,
parameters: operation.parameters,
response: operation.response,
},
arguments: Object.fromEntries(
Object.entries(arguments_).filter(([, value]) => hasValue(value)),
),
requestPath: path,
data,
truncated: state.truncated,
};
}