import { z } from "zod"; import type { ApiResponse, ApiRequestSchema, ApiResponseSchema, ApiClientLoggingOptions, RequestOptions, ExtractRequestBody, SuccessResponse, ErrorResponse, UnexpectedErrorResponse, EndpointsWithMethod, RetryContext, HttpExecutor, ExecuteResponse, } from "./types.js"; import { UnexpectedApiClientError, ValidationError } from "./errors.js"; import { createInternalLogger } from "./logging.js"; // ============================================================================ // Types // ============================================================================ export interface ApiClientOptions< TRequest extends ApiRequestSchema, TResponse extends ApiResponseSchema, TRawResponse = unknown, > extends ApiClientLoggingOptions { baseUrl: string; headers?: Record; Request: TRequest; Response: TResponse; /** * Called when Zod validation fails for request or response data. * This is only invoked for schema parse failures (i.e. when Zod would throw), * not for other validation errors like missing path params. */ onValidationError?: ApiClientValidationErrorHandler; executor: HttpExecutor; } export type ApiClientValidationErrorLocation = "params" | "query" | "body" | "response"; export type ApiClientValidationErrorContext = { kind: "request" | "response"; location: ApiClientValidationErrorLocation; endpoint: string; method: string; /** Human-readable context string (also used as the thrown ValidationError message). */ message: string; /** The data that failed validation. */ data: unknown; /** Zod issues produced by the schema. */ issues: z.ZodIssue[]; /** The underlying ZodError object. */ zodError: z.ZodError; /** Response-only context. */ status?: number; statusCode?: string; statusText?: string; raw?: TRawResponse; }; export type ApiClientValidationErrorHandler = ( context: ApiClientValidationErrorContext, ) => void; type ValidationContext = Omit< ApiClientValidationErrorContext, "data" | "issues" | "zodError" | "message" >; // ============================================================================ // Helper Functions // ============================================================================ /** * Interpolates path parameters into endpoint string */ function interpolatePath(endpoint: string, params: Record): string { let result = endpoint; for (const [key, value] of Object.entries(params)) { result = result.replace(`{${key}}`, String(value)); } return result; } /** * Builds query string from query object */ function buildQueryString(query: Record): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null) { params.append(key, String(value)); } } const queryString = params.toString(); return queryString ? `?${queryString}` : ""; } /** * Sleep for specified milliseconds */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Calculates exponential backoff delay */ function calculateBackoff(attempt: number, baseDelay: number = 1000): number { return Math.min(baseDelay * Math.pow(2, attempt), 30000); // Max 30 seconds } function formatLogError(error: unknown): Record { if (error instanceof Error) { return { name: error.name, message: error.message }; } return { error }; } function buildHeaders( clientHeaders: Record | undefined, requestHeaders: Record, ): Record { const headers: Record = { "Content-Type": "application/json", }; for (const headerSet of [clientHeaders, requestHeaders]) { if (!headerSet) continue; for (const [key, value] of Object.entries(headerSet)) { if (value !== undefined && value !== null) { headers[key] = String(value); } } } return headers; } // ============================================================================ // ApiClient Class // ============================================================================ export class ApiClient< TRequest extends ApiRequestSchema, TResponse extends ApiResponseSchema, TRawResponse = unknown, > { private readonly internalLog: ReturnType; constructor(public readonly options: ApiClientOptions) { this.internalLog = createInternalLogger(options); } private log( level: "error" | "warn" | "info" | "debug", message: string, meta?: Record, ): void { this.internalLog(level, message, meta); } private safeInvokeOnValidationError( context: ApiClientValidationErrorContext, ): void { if (!this.options.onValidationError) return; try { this.options.onValidationError(context); } catch { // Never allow a user-provided callback to affect control-flow, retries, etc. } } private validateSchema( schema: TSchema, data: unknown, message: string, context: ValidationContext, ): z.output { const parsed = schema.safeParse(data); if (parsed.success) return parsed.data; return this.throwValidationError(parsed.error, data, message, context); } private throwValidationError( error: z.ZodError, data: unknown, message: string, context: ValidationContext, ): never { this.safeInvokeOnValidationError({ ...context, message, data, issues: error.issues, zodError: error, }); this.log("error", "HTTP validation failed", { kind: context.kind, location: context.location, endpoint: context.endpoint, method: context.method, status: context.status, statusText: context.statusText, message, }); throw new ValidationError(message, error.issues, context.endpoint, context.method); } private validateAndEncodeSchema( schema: TSchema, data: unknown, message: string, context: ValidationContext, ): z.input { const parsed = this.validateSchema(schema, data, message, context); try { return z.encode(schema, parsed); } catch (error: unknown) { if (error instanceof z.ZodError) { return this.throwValidationError(error, data, message, context); } throw error; } } /** * Makes a GET request */ async get>( endpoint: TEndpoint, options: RequestOptions, ): Promise> { return this.request("GET", endpoint, options); } /** * Makes a POST request */ async post>( endpoint: TEndpoint, options: RequestOptions & { body: ExtractRequestBody; }, ): Promise> { return this.request("POST", endpoint, options); } /** * Makes a PUT request */ async put>( endpoint: TEndpoint, options: RequestOptions & { body: ExtractRequestBody; }, ): Promise> { return this.request("PUT", endpoint, options); } /** * Makes a PATCH request */ async patch>( endpoint: TEndpoint, options: RequestOptions & { body: ExtractRequestBody; }, ): Promise> { return this.request("PATCH", endpoint, options); } /** * Makes a DELETE request */ async delete>( endpoint: TEndpoint, options: RequestOptions, ): Promise> { return this.request("DELETE", endpoint, options); } /** * Internal request method */ private async request< TEndpoint extends keyof TRequest & string, TMethod extends keyof TRequest[TEndpoint] & string, >( method: TMethod, endpoint: TEndpoint, options: RequestOptions & { body?: ExtractRequestBody; }, ): Promise> { const { params = {}, query = {}, body, timeout, retries = 0, headers: requestHeaders = {}, shouldRetry, } = options; // Validate and interpolate path const wirePathParams = this.validatePathParams(endpoint, params, method); const interpolatedPath = interpolatePath(endpoint, wirePathParams); // Validate query if schema exists const wireQuery = this.validateQuery(endpoint, method, query); // Validate body if schema exists const wireBody = body === undefined ? undefined : this.validateBody(endpoint, method, body); // Build URL const queryString = buildQueryString(wireQuery); const url = `${this.options.baseUrl}${interpolatedPath}${queryString}`; // Merge headers const headers = buildHeaders(this.options.headers, requestHeaders); // Make request with retry logic let lastError: unknown; let lastResult: ExecuteResponse | undefined; let attempt = 0; while (attempt <= retries) { try { const result = await this.options.executor.execute({ method, url, headers, body: wireBody !== undefined && (method === "POST" || method === "PUT" || method === "PATCH") ? JSON.stringify(wireBody) : undefined, timeout, }); // Check custom shouldRetry for response-based retry (e.g., 5xx errors) if (shouldRetry && attempt < retries) { const retryContext: RetryContext = { attempt, response: { status: result.status, statusText: result.statusText, data: result.data }, }; if (shouldRetry(retryContext)) { this.log("warn", "HTTP retry requested (response)", { method, endpoint, status: result.status, statusText: result.statusText, attempt, retries, }); lastResult = result; const delay = calculateBackoff(attempt); await sleep(delay); attempt++; continue; } } return this.handleResponse(result, endpoint, method) as ApiResponse< TResponse, TEndpoint, TMethod, TRawResponse >; } catch (error: unknown) { lastError = error; // Don't retry on validation errors if (error instanceof ValidationError) { throw error; } // Check custom shouldRetry or fall back to default behavior if (attempt < retries) { const retryContext: RetryContext = { attempt, error }; if (shouldRetry) { if (shouldRetry(retryContext)) { this.log("warn", "HTTP retry requested (error)", { method, endpoint, attempt, retries, ...formatLogError(error), }); const delay = calculateBackoff(attempt); await sleep(delay); attempt++; continue; } // Custom shouldRetry returned false, stop retrying break; } // Default behavior: don't retry on 4xx client errors if ( error instanceof UnexpectedApiClientError && error.code !== undefined && error.code >= 400 && error.code < 500 ) { this.log("error", "HTTP request failed with non-retriable status", { method, endpoint, status: error.code, ...formatLogError(error), }); throw error; } // Default: retry on network errors this.log("warn", "HTTP retrying after error", { method, endpoint, attempt, retries, ...formatLogError(error), }); const delay = calculateBackoff(attempt); await sleep(delay); attempt++; } else { break; } } } // If we have a last result (from response-based retry), return it if (lastResult) { return this.handleResponse(lastResult, endpoint, method) as ApiResponse< TResponse, TEndpoint, TMethod, TRawResponse >; } // If we get here, all retries failed this.log("error", "HTTP request failed after retries", { method, endpoint, attempts: attempt, retries, ...formatLogError(lastError), }); if (lastError instanceof Error) { throw lastError; } throw new UnexpectedApiClientError( "Request failed after retries", undefined, endpoint, method, lastError, ); } /** * Validates path parameters against Request schema */ private validatePathParams< TEndpoint extends keyof TRequest & string, TMethod extends keyof TRequest[TEndpoint] & string, >( endpoint: TEndpoint, params: Record, method: TMethod, ): Record { const requestDef = this.options.Request[endpoint]?.[method]; if (!requestDef || typeof requestDef !== "object") { // No schema, but check if path has params const requiredParams = this.getPathParamNames(endpoint); if (requiredParams.length > 0 && Object.keys(params).length === 0) { this.log("error", "HTTP request validation failed", { method, endpoint, missing: requiredParams, }); throw new ValidationError( `Missing required path parameters: ${requiredParams.join(", ")}`, { missing: requiredParams }, endpoint, method, ); } return params; } const paramsSchema = (requestDef as { params?: z.ZodTypeAny }).params; if (paramsSchema) { return this.validateAndEncodeSchema( paramsSchema, params, "Path parameters validation failed", { kind: "request", location: "params", endpoint, method, }, ) as Record; } // Check if endpoint requires params but none provided const requiredParams = this.getPathParamNames(endpoint); if (requiredParams.length > 0 && Object.keys(params).length === 0) { this.log("error", "HTTP request validation failed", { method, endpoint, missing: requiredParams, }); throw new ValidationError( `Missing required path parameters: ${requiredParams.join(", ")}`, { missing: requiredParams }, endpoint, method, ); } return params; } /** * Validates query parameters against Request schema */ private validateQuery< TEndpoint extends keyof TRequest & string, TMethod extends keyof TRequest[TEndpoint] & string, >( endpoint: TEndpoint, method: TMethod, query: Record, ): Record { const requestDef = this.options.Request[endpoint]?.[method]; if (!requestDef || typeof requestDef !== "object") { return query; } const querySchema = (requestDef as { query?: z.ZodTypeAny }).query; if (querySchema) { return this.validateAndEncodeSchema( querySchema, query, "Query parameters validation failed", { kind: "request", location: "query", endpoint, method, }, ) as Record; } return query; } /** * Validates body against Request schema */ private validateBody< TEndpoint extends keyof TRequest & string, TMethod extends keyof TRequest[TEndpoint] & string, >(endpoint: TEndpoint, method: TMethod, body: unknown): unknown { const requestDef = this.options.Request[endpoint]?.[method]; if (!requestDef || typeof requestDef !== "object") { return body; } const bodySchema = (requestDef as { body?: z.ZodTypeAny }).body; if (bodySchema) { return this.validateAndEncodeSchema(bodySchema, body, "Request body validation failed", { kind: "request", location: "body", endpoint, method, }); } return body; } /** * Gets path parameter names from endpoint string */ private getPathParamNames(endpoint: string): string[] { const matches = endpoint.matchAll(/\{([^}]+)\}/g); return Array.from(matches, (m) => m[1]).filter((name): name is string => name !== undefined); } /** * Handles the response and returns discriminated union */ private handleResponse( result: ExecuteResponse, endpoint: string, method: string, ): | SuccessResponse | ErrorResponse | UnexpectedErrorResponse { const { status, statusText, data, raw } = result; const statusCode = String(status); // Get schema from Response for this status code const responseSchema = endpoint in this.options.Response ? this.options.Response[endpoint]?.[method]?.[statusCode] : undefined; if (!responseSchema) { // No schema defined for this status code if (statusCode.startsWith("2")) { return { success: true, body: data, code: statusCode, raw, } as SuccessResponse; } this.log("error", "HTTP error response", { method, endpoint, status, statusText, }); return { success: false, error: new UnexpectedApiClientError( `Unexpected error response: ${statusText}`, status, endpoint, method, data, ), code: status, raw, } as UnexpectedErrorResponse; } // Validate against schema try { const validated = this.validateSchema( responseSchema, data, `Response validation failed for ${statusCode}`, { kind: "response", location: "response", endpoint, method, status, statusCode, statusText, raw, }, ); if (statusCode.startsWith("2")) { return { success: true, body: validated, code: statusCode, raw, } as SuccessResponse; } this.log("error", "HTTP error response", { method, endpoint, status, statusText, }); return { success: false, error: validated, code: statusCode, raw, } as ErrorResponse; } catch { // Validation failed return { success: false, error: new UnexpectedApiClientError( `Response validation failed: ${statusText}`, status, endpoint, method, data, ), code: status, raw, } as UnexpectedErrorResponse; } } }