/**
* Generic API executor for making authenticated Onboarded API calls.
*
* Takes operation details from the OpenAPI spec and executes the call
* with proper authentication and parameter handling.
*/
import type { ApiType, EnvType, Parameter, SchemaObject } from "./spec.js";
import { describeOperation, getSpec } from "./spec.js";
import { getToken, type GetTokenOptions } from "./keychain.js";
const BASE_URLS: Record<EnvType, Record<ApiType, string>> = {
prod: {
v1: "https://app.onboarded.com",
internal: "https://app.onboarded.com",
},
staging: {
v1: "https://staging.onboarded.com",
internal: "https://staging.onboarded.com",
},
};
export interface ApiCallInput {
api: ApiType;
env?: EnvType;
operationId: string;
params?: Record<string, unknown>;
body?: unknown;
profile?: string;
dryRun?: boolean;
}
export interface ApiCallResult {
success: boolean;
status?: number;
statusText?: string;
data?: unknown;
error?: string;
request?: {
method: string;
url: string;
headers: Record<string, string>;
body?: unknown;
};
}
/**
* Build the full URL for an API call, substituting path parameters.
*/
function buildUrl(
baseUrl: string,
path: string,
pathParams: Record<string, unknown>,
queryParams: Record<string, unknown>
): string {
// Substitute path parameters
let url = path;
for (const [key, value] of Object.entries(pathParams)) {
url = url.replace(`{${key}}`, encodeURIComponent(String(value)));
}
// Build full URL
const fullUrl = new URL(baseUrl + url);
// Add query parameters
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
for (const item of value) {
fullUrl.searchParams.append(key, String(item));
}
} else {
fullUrl.searchParams.set(key, String(value));
}
}
}
return fullUrl.toString();
}
/**
* Separate parameters into path, query, and header categories.
*/
function categorizeParams(
parameters: Parameter[],
inputParams: Record<string, unknown>
): {
pathParams: Record<string, unknown>;
queryParams: Record<string, unknown>;
headerParams: Record<string, unknown>;
} {
const pathParams: Record<string, unknown> = {};
const queryParams: Record<string, unknown> = {};
const headerParams: Record<string, unknown> = {};
for (const param of parameters) {
const value = inputParams[param.name];
if (value === undefined) {
continue;
}
switch (param.in) {
case "path":
pathParams[param.name] = value;
break;
case "query":
queryParams[param.name] = value;
break;
case "header":
headerParams[param.name] = value;
break;
}
}
return { pathParams, queryParams, headerParams };
}
/**
* Validate required parameters.
*/
function validateParams(
parameters: Parameter[],
inputParams: Record<string, unknown>
): string[] {
const errors: string[] = [];
for (const param of parameters) {
if (param.required && inputParams[param.name] === undefined) {
errors.push(`Missing required parameter: ${param.name} (${param.in})`);
}
}
return errors;
}
/**
* Execute an API call based on an OpenAPI operation.
*/
export async function executeApiCall(input: ApiCallInput): Promise<ApiCallResult> {
const { api, env = "prod", operationId, params = {}, body, profile, dryRun } = input;
// Get operation details from spec
const opDetails = describeOperation(api, operationId, env);
if (!opDetails) {
return {
success: false,
error: `Operation '${operationId}' not found in ${api} spec (${env}). Did you run spec.sync?`,
};
}
const { operation, parameters } = opDetails;
// Validate required parameters
const validationErrors = validateParams(parameters, params);
if (validationErrors.length > 0) {
return {
success: false,
error: `Parameter validation failed:\n${validationErrors.join("\n")}`,
};
}
// Categorize parameters
const { pathParams, queryParams, headerParams } = categorizeParams(parameters, params);
// Build URL
const baseUrl = BASE_URLS[env][api];
const url = buildUrl(baseUrl, operation.path, pathParams, queryParams);
// Build headers
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
...Object.fromEntries(
Object.entries(headerParams).map(([k, v]) => [k, String(v)])
),
};
// Get auth token
const tokenOptions: GetTokenOptions = {};
if (profile) {
tokenOptions.profile = profile;
}
const tokenResult = getToken(tokenOptions);
if (!tokenResult.found) {
return {
success: false,
error: `No auth token found. ${tokenResult.error ?? "Run 'onboarded auth login' to authenticate."}`,
};
}
headers["Authorization"] = `Bearer ${tokenResult.token}`;
// Prepare request info for dry run
const requestInfo = {
method: operation.method,
url,
headers: { ...headers, Authorization: "Bearer [REDACTED]" },
body,
};
// Dry run - return the request that would be made
if (dryRun) {
return {
success: true,
request: requestInfo,
};
}
// Execute the actual request
try {
const fetchOptions: RequestInit = {
method: operation.method,
headers,
};
if (body && ["POST", "PUT", "PATCH"].includes(operation.method)) {
fetchOptions.body = JSON.stringify(body);
}
const response = await fetch(url, fetchOptions);
let data: unknown;
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
data = await response.json();
} else {
data = await response.text();
}
return {
success: response.ok,
status: response.status,
statusText: response.statusText,
data,
request: requestInfo,
error: response.ok ? undefined : `API returned ${response.status}: ${response.statusText}`,
};
} catch (error) {
return {
success: false,
error: `Request failed: ${error instanceof Error ? error.message : String(error)}`,
request: requestInfo,
};
}
}
/**
* Get the base URL for an API/environment combination.
*/
export function getBaseUrl(api: ApiType, env: EnvType = "prod"): string {
return BASE_URLS[env][api];
}
/**
* Generate a description of the required and optional parameters for an operation.
*/
export function describeOperationParams(
api: ApiType,
operationId: string,
env: EnvType = "prod"
): string | null {
const opDetails = describeOperation(api, operationId, env);
if (!opDetails) {
return null;
}
const { operation, parameters, requestBodySchema } = opDetails;
const lines: string[] = [];
lines.push(`# ${operation.method} ${operation.path}`);
if (operation.summary) {
lines.push(`## ${operation.summary}`);
}
if (operation.description) {
lines.push(operation.description);
}
lines.push("");
// Parameters
if (parameters.length > 0) {
lines.push("## Parameters");
for (const param of parameters) {
const required = param.required ? " (required)" : "";
const type = param.schema?.type ?? "any";
lines.push(`- **${param.name}**${required}: ${type}${param.description ? ` - ${param.description}` : ""}`);
}
lines.push("");
}
// Request body
if (requestBodySchema) {
lines.push("## Request Body");
lines.push(formatSchema(requestBodySchema));
lines.push("");
}
return lines.join("\n");
}
/**
* Format a schema object as a readable string.
*/
function formatSchema(schema: SchemaObject, indent: number = 0): string {
const pad = " ".repeat(indent);
if (schema.$ref) {
// Handle $ref (simplified - just show the reference)
const refName = schema.$ref.split("/").pop();
return `${pad}$ref: ${refName}`;
}
if (schema.type === "object" && schema.properties) {
const lines: string[] = [];
const required = new Set(schema.required ?? []);
for (const [key, propSchema] of Object.entries(schema.properties)) {
const isRequired = required.has(key);
const reqMarker = isRequired ? " (required)" : "";
const type = propSchema.type ?? "any";
const desc = propSchema.description ? ` - ${propSchema.description}` : "";
if (propSchema.type === "object" && propSchema.properties) {
lines.push(`${pad}- **${key}**${reqMarker}: object${desc}`);
lines.push(formatSchema(propSchema, indent + 1));
} else if (propSchema.type === "array" && propSchema.items) {
const itemType = propSchema.items.type ?? "any";
lines.push(`${pad}- **${key}**${reqMarker}: array of ${itemType}${desc}`);
} else {
lines.push(`${pad}- **${key}**${reqMarker}: ${type}${desc}`);
}
}
return lines.join("\n");
}
return `${pad}${schema.type ?? "any"}`;
}