Shaheer Sarfaraz b94f85b149
Reduce low risk duplication (#79)
* clean up helpers

* shared in it's own top level folder

* workspaces setup

* build fix

* disable workspaces?

* run ci

* rename job-flow to gradcracker

* optional dependencies

* formatting?

* more optional modules

* allow post install runs

* node bump

* remove post install

* add optionals

* add more

* formatting

* comments, but im unsure

* run typescript DIRECTLY

* better build

* camoufox simplification

* lint

* build process doesn't exist

* build fix

* lockfile

* type check everything, build only for client

* rename steps correctly

* import from package!

* fix formatting

* don't fetch twice

* fix concern
2026-02-02 21:30:14 +00:00

198 lines
5.9 KiB
TypeScript

// rxresume-v5.ts
// Future-facing v5/OpenAPI implementation that uses API keys.
// - Kept alongside v4 files so we can swap imports when v5 is ready.
// - Uses RXRESUME_API_KEY and /api/openapi endpoints.
//
// NOTE: Not currently wired in; keep for migration.
import { resumeDataSchema } from "@shared/rxresume-schema";
export interface RxResumeResponse {
id: string;
name: string;
slug: string;
data: unknown;
[key: string]: unknown;
}
/**
* Temporary helper to execute a fetch request with multiple API keys if in development.
* THIS FUNCTION IS TEMPORARY AND WILL BE REMOVED.
*/
// Cache for last working key index (temporary, part of dev-only logic)
let lastWorkingKeyIndex = 0;
async function executeWithKeyRetries(
url: string,
options: RequestInit,
): Promise<unknown> {
const rawApiKey = process.env.RXRESUME_API_KEY;
if (!rawApiKey) {
throw new Error("RXRESUME_API_KEY not configured in environment");
}
const isDev = process.env.NODE_ENV !== "production";
const apiKeys =
isDev && rawApiKey.includes(",")
? rawApiKey.split(",").map((k) => k.trim())
: [rawApiKey];
// Start from the last working key index
for (let attempt = 0; attempt < apiKeys.length; attempt++) {
const i = (lastWorkingKeyIndex + attempt) % apiKeys.length;
const apiKey = apiKeys[i];
const headers = {
"x-api-key": apiKey,
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
} as Record<string, string>;
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const errorData = (await response
.json()
.catch(() => ({ message: response.statusText }))) as {
message?: string;
};
const errorMsg = `Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`;
// ONLY retry/rotation on 401 Unauthorized
if (
response.status === 401 &&
apiKeys.length > 1 &&
attempt < apiKeys.length - 1
) {
console.warn(
`[RxResume SDK] Key index ${i} was Unauthorized, trying next key...`,
);
continue;
}
throw new Error(errorMsg);
}
// Success! Cache this key index for future requests
lastWorkingKeyIndex = i;
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
return response.json();
}
return response.text();
}
// Unmissable error block if all keys fail
if (apiKeys.length > 1) {
console.error(`
################################################################################
# #
# ❌ ALL REACTIVE RESUME API KEYS FAILED (${apiKeys.length} keys attempted) #
# Please check your .env configuration. #
# #
################################################################################
`);
}
throw new Error("All Reactive Resume API keys failed.");
}
/**
* Generic fetch helper for Reactive Resume API
*/
export async function fetchRxResume(
path: string,
options: RequestInit = {},
): Promise<unknown> {
const baseUrl = process.env.RXRESUME_URL || "https://rxresu.me";
let cleanBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
// Handle cases where the base URL already includes /api or /api/openapi
if (cleanBaseUrl.endsWith("/api/openapi")) {
cleanBaseUrl = cleanBaseUrl.slice(0, -12);
} else if (cleanBaseUrl.endsWith("/api")) {
cleanBaseUrl = cleanBaseUrl.slice(0, -4);
}
const url = `${cleanBaseUrl}/api/openapi${path}`;
return executeWithKeyRetries(url, options);
}
/**
* Fetch a resume by its ID.
*/
export async function getResume(id: string): Promise<RxResumeResponse> {
return (await fetchRxResume(`/resume/${id}`)) as RxResumeResponse;
}
/**
* Import a resume.
*/
export async function importResume(payload: {
name: string;
slug: string;
data: unknown;
}): Promise<string> {
// Validate data against schema before sending
try {
payload.data = resumeDataSchema.parse(payload.data);
} catch (error) {
console.error("❌ Resume data validation failed:", error);
throw error;
}
// DEBUG: Save payload to file for debugging (temporary)
try {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const debugDir = path.join(process.cwd(), "debug");
await fs.mkdir(debugDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = path.join(debugDir, `rxresume-import-${timestamp}.json`);
await fs.writeFile(filename, JSON.stringify(payload, null, 2), "utf-8");
console.log(`📝 DEBUG: Saved import payload to ${filename}`);
} catch (debugErr) {
console.warn("⚠️ Could not save debug file:", debugErr);
}
const result = (await fetchRxResume("/resume/import", {
method: "POST",
body: JSON.stringify(payload),
})) as { id: string } | string;
// Reactive Resume returns the full resume object on import in v4+, or just ID in v5.
return typeof result === "string" ? result : result.id;
}
/**
* Delete a resume.
*/
export async function deleteResume(id: string): Promise<void> {
await fetchRxResume(`/resume/${id}`, { method: "DELETE" });
}
/**
* Export a resume as PDF. Returns the URL.
*/
export async function exportResumePdf(id: string): Promise<string> {
const result = (await fetchRxResume(`/printer/resume/${id}/pdf`)) as {
url: string;
};
return result.url;
}
/**
* List all resumes.
* According to official OpenAPI spec, the endpoint is /resume/list
*/
export async function listResumes(): Promise<{ id: string; name: string }[]> {
return (await fetchRxResume("/resume/list")) as {
id: string;
name: string;
}[];
}