| import { URL } from "node:url"; |
|
|
| export class ApiRequestError extends Error { |
| status: number; |
| details?: unknown; |
| body?: unknown; |
|
|
| constructor(status: number, message: string, details?: unknown, body?: unknown) { |
| super(message); |
| this.status = status; |
| this.details = details; |
| this.body = body; |
| } |
| } |
|
|
| export class ApiConnectionError extends Error { |
| url: string; |
| method: string; |
| causeMessage?: string; |
|
|
| constructor(input: { |
| apiBase: string; |
| path: string; |
| method: string; |
| cause?: unknown; |
| }) { |
| const url = buildUrl(input.apiBase, input.path); |
| const causeMessage = formatConnectionCause(input.cause); |
| super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage })); |
| this.url = url; |
| this.method = input.method; |
| this.causeMessage = causeMessage; |
| } |
| } |
|
|
| interface RequestOptions { |
| ignoreNotFound?: boolean; |
| } |
|
|
| interface RecoverAuthInput { |
| path: string; |
| method: string; |
| error: ApiRequestError; |
| } |
|
|
| interface ApiClientOptions { |
| apiBase: string; |
| apiKey?: string; |
| runId?: string; |
| recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>; |
| } |
|
|
| export class PaperclipApiClient { |
| readonly apiBase: string; |
| apiKey?: string; |
| readonly runId?: string; |
| readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>; |
|
|
| constructor(opts: ApiClientOptions) { |
| this.apiBase = opts.apiBase.replace(/\/+$/, ""); |
| this.apiKey = opts.apiKey?.trim() || undefined; |
| this.runId = opts.runId?.trim() || undefined; |
| this.recoverAuth = opts.recoverAuth; |
| } |
|
|
| get<T>(path: string, opts?: RequestOptions): Promise<T | null> { |
| return this.request<T>(path, { method: "GET" }, opts); |
| } |
|
|
| post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> { |
| return this.request<T>(path, { |
| method: "POST", |
| body: body === undefined ? undefined : JSON.stringify(body), |
| }, opts); |
| } |
|
|
| patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> { |
| return this.request<T>(path, { |
| method: "PATCH", |
| body: body === undefined ? undefined : JSON.stringify(body), |
| }, opts); |
| } |
|
|
| delete<T>(path: string, opts?: RequestOptions): Promise<T | null> { |
| return this.request<T>(path, { method: "DELETE" }, opts); |
| } |
|
|
| setApiKey(apiKey: string | undefined) { |
| this.apiKey = apiKey?.trim() || undefined; |
| } |
|
|
| private async request<T>( |
| path: string, |
| init: RequestInit, |
| opts?: RequestOptions, |
| hasRetriedAuth = false, |
| ): Promise<T | null> { |
| const url = buildUrl(this.apiBase, path); |
| const method = String(init.method ?? "GET").toUpperCase(); |
|
|
| const headers: Record<string, string> = { |
| accept: "application/json", |
| ...toStringRecord(init.headers), |
| }; |
|
|
| if (init.body !== undefined) { |
| headers["content-type"] = headers["content-type"] ?? "application/json"; |
| } |
|
|
| if (this.apiKey) { |
| headers.authorization = `Bearer ${this.apiKey}`; |
| } |
|
|
| if (this.runId) { |
| headers["x-paperclip-run-id"] = this.runId; |
| } |
|
|
| let response: Response; |
| try { |
| response = await fetch(url, { |
| ...init, |
| headers, |
| }); |
| } catch (error) { |
| throw new ApiConnectionError({ |
| apiBase: this.apiBase, |
| path, |
| method, |
| cause: error, |
| }); |
| } |
|
|
| if (opts?.ignoreNotFound && response.status === 404) { |
| return null; |
| } |
|
|
| if (!response.ok) { |
| const apiError = await toApiError(response); |
| if (!hasRetriedAuth && this.recoverAuth) { |
| const recoveredToken = await this.recoverAuth({ |
| path, |
| method, |
| error: apiError, |
| }); |
| if (recoveredToken) { |
| this.setApiKey(recoveredToken); |
| return this.request<T>(path, init, opts, true); |
| } |
| } |
| throw apiError; |
| } |
|
|
| if (response.status === 204) { |
| return null; |
| } |
|
|
| const text = await response.text(); |
| if (!text.trim()) { |
| return null; |
| } |
|
|
| return safeParseJson(text) as T; |
| } |
| } |
|
|
| function buildUrl(apiBase: string, path: string): string { |
| const normalizedPath = path.startsWith("/") ? path : `/${path}`; |
| const [pathname, query] = normalizedPath.split("?"); |
| const url = new URL(apiBase); |
| url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`; |
| if (query) url.search = query; |
| return url.toString(); |
| } |
|
|
| function safeParseJson(text: string): unknown { |
| try { |
| return JSON.parse(text); |
| } catch { |
| return text; |
| } |
| } |
|
|
| async function toApiError(response: Response): Promise<ApiRequestError> { |
| const text = await response.text(); |
| const parsed = safeParseJson(text); |
|
|
| if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { |
| const body = parsed as Record<string, unknown>; |
| const message = |
| (typeof body.error === "string" && body.error.trim()) || |
| (typeof body.message === "string" && body.message.trim()) || |
| `Request failed with status ${response.status}`; |
|
|
| return new ApiRequestError(response.status, message, body.details, parsed); |
| } |
|
|
| return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed); |
| } |
|
|
| function buildConnectionErrorMessage(input: { |
| apiBase: string; |
| url: string; |
| method: string; |
| causeMessage?: string; |
| }): string { |
| const healthUrl = buildHealthCheckUrl(input.url); |
| const lines = [ |
| "Could not reach the Paperclip API.", |
| "", |
| `Request: ${input.method} ${input.url}`, |
| ]; |
| if (input.causeMessage) { |
| lines.push(`Cause: ${input.causeMessage}`); |
| } |
| lines.push( |
| "", |
| "This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.", |
| "", |
| "Try:", |
| "- Start Paperclip with `npx penclip run`, or use `pnpm dev` inside a local Paperclip repo checkout.", |
| `- Verify the server is reachable with \`curl ${healthUrl}\`.`, |
| `- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`, |
| ); |
| return lines.join("\n"); |
| } |
|
|
| function buildHealthCheckUrl(requestUrl: string): string { |
| const url = new URL(requestUrl); |
| url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`; |
| url.search = ""; |
| url.hash = ""; |
| return url.toString(); |
| } |
|
|
| function formatConnectionCause(error: unknown): string | undefined { |
| if (!error) return undefined; |
| if (error instanceof Error) { |
| return error.message.trim() || error.name; |
| } |
| const message = String(error).trim(); |
| return message || undefined; |
| } |
|
|
| function toStringRecord(headers: HeadersInit | undefined): Record<string, string> { |
| if (!headers) return {}; |
| if (Array.isArray(headers)) { |
| return Object.fromEntries(headers.map(([key, value]) => [key, String(value)])); |
| } |
| if (headers instanceof Headers) { |
| return Object.fromEntries(headers.entries()); |
| } |
| return Object.fromEntries( |
| Object.entries(headers).map(([key, value]) => [key, String(value)]), |
| ); |
| } |
|
|