UNPKG

@follow-app/client-sdk

Version:

TypeScript client SDK for Follow RSS Server API

476 lines (416 loc) 12.9 kB
import type { ClientConfig, FollowAPIErrorResponse, FollowAPIResponse, RequestContentType, RequestOptions, } from "../types" import { FollowAPIError, FollowAuthError, FollowTimeoutError, FollowValidationError, } from "../types/errors" import { InterceptorManager } from "./interceptors" /** * Core HTTP client for Follow API using native fetch */ export class HttpClient { private config: Required<ClientConfig> private fetchInstance: typeof fetch private interceptors: InterceptorManager constructor(config: ClientConfig) { this.config = { timeout: 30000, headers: {}, credentials: "include", fetch: globalThis.fetch, ...config, } this.fetchInstance = this.config.fetch this.interceptors = new InterceptorManager() } /** * Build URL with query parameters */ private buildURL( path: string, query?: Record<string, string | number | boolean | string[]>, ): string { const url = new URL(path, this.config.baseURL) if (query) { Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null) { if (Array.isArray(value)) { value.forEach((v) => { url.searchParams.append(key, String(v)) }) } else { url.searchParams.append(key, String(value)) } } }) } return url.toString() } /** * Process request body based on content type */ private processRequestBody( body: unknown, requestType?: RequestContentType | undefined, ): { processedBody: BodyInit | undefined, headers: Record<string, string> } { if (!body) { return { processedBody: undefined, headers: {} } } if (!requestType) { if (body instanceof FormData) { requestType = "formData" } else if (body instanceof ArrayBuffer) { requestType = "arrayBuffer" } else if (body instanceof Blob) { requestType = "blob" } else { requestType = "json" } } switch (requestType) { case "json": { return { processedBody: JSON.stringify(body), headers: { "Content-Type": "application/json" }, } } case "formData": { if (body instanceof FormData) { return { processedBody: body, headers: {} } } if (typeof body === "object" && body !== null) { const formData = new FormData() Object.entries(body).forEach(([key, value]) => { if (value instanceof File || value instanceof Blob) { formData.append(key, value) } else if (value !== undefined && value !== null) { formData.append(key, String(value)) } }) return { processedBody: formData, headers: {} } } throw new Error("Invalid body type for formData request") } case "text": { return { processedBody: typeof body === "string" ? body : String(body), headers: { "Content-Type": "text/plain" }, } } case "blob": { if (body instanceof Blob) { return { processedBody: body, headers: {} } } throw new Error("Body must be a Blob for blob request type") } case "arrayBuffer": { if (body instanceof ArrayBuffer) { return { processedBody: body, headers: { "Content-Type": "application/octet-stream" }, } } throw new Error( "Body must be an ArrayBuffer for arrayBuffer request type", ) } default: { throw new Error(`Unsupported request type: ${requestType}`) } } } /** * Handle response parsing and error handling */ private async handleResponse<T>( response: Response, originalPath: string, finalUrl: string, requestOptions: RequestOptions, ): Promise<T> { const contentType = response.headers?.get("content-type") || "" if (!response.ok) { let errorData: FollowAPIErrorResponse | null = null // Try to parse error response if (contentType.includes("application/json")) { try { errorData = await response.json() } catch { // Fall back to status text if JSON parsing fails errorData = { code: response.status, message: response.statusText } } } else { errorData = { code: response.status, message: response.statusText } } // Extract pathname from final URL for error context const finalPathname = new URL(finalUrl).pathname // Create detailed error context const requestContext = { originalPath, finalPathname, method: requestOptions.method || "GET", query: requestOptions.query, body: requestOptions.body, headers: requestOptions.headers, } const contextStr = `${requestContext.method} ${finalPathname} (original: ${originalPath})` const argsStr = JSON.stringify( { query: requestContext.query, body: requestContext.body, headers: requestContext.headers, }, null, 2, ) // Handle specific error types if (response.status === 401) { throw new FollowAuthError( `${errorData?.message || "Authentication required"}\nRequest: ${contextStr}\nArgs: ${argsStr}`, errorData, ) } if (response.status === 400 && errorData?.data) { throw new FollowValidationError( `${errorData.message || "Validation error"}\nRequest: ${contextStr}\nArgs: ${argsStr}`, Array.isArray(errorData.data) ? errorData.data : [errorData.data], ) } throw new FollowAPIError( `${errorData?.message || response.statusText}\nRequest: ${contextStr}\nArgs: ${argsStr}`, response.status, errorData?.code?.toString(), errorData?.data, ) } // Handle successful responses based on content type return requestOptions.asRaw ? response as unknown as T : this.parseResponseByContentType<T>(response, contentType) } /** * Parse response based on content type */ private async parseResponseByContentType<T>( response: Response, contentType: string, ): Promise<T> { // Handle event stream if (contentType.includes("text/event-stream")) { return response as unknown as T } // Handle JSON responses if (contentType.includes("application/json")) { const jsonResponse = await response.json() // Handle Follow API response format if (typeof jsonResponse === "object" && "code" in jsonResponse) { const apiResponse = jsonResponse as FollowAPIResponse<T> return apiResponse as unknown as T } return jsonResponse } // Handle blob responses if ( contentType.includes("application/octet-stream") || contentType.includes("image/") || contentType.includes("video/") || contentType.includes("audio/") ) { const blobResponse = await response.blob() return blobResponse as unknown as T } // Handle text responses if (contentType.includes("text/")) { const textResponse = await response.text() return textResponse as T } // Default to arrayBuffer for unknown binary types if (contentType.includes("application/")) { const arrayBufferResponse = await response.arrayBuffer() return arrayBufferResponse as unknown as T } // Fallback to text const textResponse = await response.text() return textResponse as T } /** * Make an HTTP request */ async request<T>(path: string, options: RequestOptions = {}): Promise<T> { let currentUrl = this.buildURL(path, options.query) let currentOptions = options let response: Response | null = null try { // Process request interceptors const interceptedRequest = await this.interceptors.processRequest( currentUrl, currentOptions, ) currentUrl = interceptedRequest.url currentOptions = interceptedRequest.options const timeout = currentOptions.timeout || this.config.timeout // Create abort controller for timeout const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) // Combine signals if user provided one let { signal } = controller if (currentOptions.signal) { const combinedController = new AbortController() const cleanup = () => { clearTimeout(timeoutId) combinedController.abort() } currentOptions.signal.addEventListener("abort", cleanup) controller.signal.addEventListener("abort", cleanup) signal = combinedController.signal } // Process request body based on content type const { processedBody, headers: bodyHeaders } = this.processRequestBody( currentOptions.body, currentOptions.requestType, ) response = await this.fetchInstance(currentUrl, { method: currentOptions.method || "GET", headers: { ...this.config.headers, ...bodyHeaders, ...currentOptions.headers, }, credentials: this.config.credentials, body: processedBody, signal, }) clearTimeout(timeoutId) // Process response interceptors const interceptedResponse = await this.interceptors.processResponse( response, currentUrl, currentOptions, ) return this.handleResponse<T>( interceptedResponse, path, currentUrl, currentOptions, ) } catch (error) { const processedError = await this.interceptors.processError( error instanceof Error ? error : new Error("Unknown error"), response, currentUrl, currentOptions, ) if (processedError) { throw processedError } // Handle specific error types if (error instanceof DOMException && error.name === "AbortError") { throw new FollowTimeoutError("Request timeout") } // Re-throw our custom errors if (error instanceof FollowAPIError) { throw error } throw error } } /** * Convenience methods for different HTTP verbs */ async get<T>( path: string, options?: Omit<RequestOptions, "method">, ): Promise<T> { return this.request<T>(path, { ...options, method: "GET" }) } async post<T>( path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">, ): Promise<T> { return this.request<T>(path, { ...options, method: "POST", body }) } async put<T>( path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">, ): Promise<T> { return this.request<T>(path, { ...options, method: "PUT", body }) } async patch<T>( path: string, body?: unknown, options?: Omit<RequestOptions, "method" | "body">, ): Promise<T> { return this.request<T>(path, { ...options, method: "PATCH", body }) } /** * Convenience method for form data uploads */ async postForm<T>( path: string, formData: FormData | Record<string, unknown>, options?: Omit<RequestOptions, "method" | "body" | "requestType">, ): Promise<T> { return this.request<T>(path, { ...options, method: "POST", body: formData, requestType: "formData", }) } /** * Convenience method for event stream responses */ async getStream( path: string, options?: Omit<RequestOptions, "method">, ): Promise<Response> { return this.request<Response>(path, { ...options, method: "GET" }) } async delete<T>( path: string, options?: Omit<RequestOptions, "method">, ): Promise<T> { return this.request<T>(path, { ...options, method: "DELETE" }) } /** * Update client configuration */ setConfig(config: Partial<ClientConfig>): void { this.config = { ...this.config, ...config } if (config.fetch) { this.fetchInstance = config.fetch } } /** * Set additional headers */ setHeaders(headers: Record<string, string>): void { this.config.headers = { ...this.config.headers, ...headers } } /** * Set custom fetch instance */ setFetch(fetchInstance: typeof fetch): void { this.fetchInstance = fetchInstance this.config.fetch = fetchInstance } /** * Get current configuration (readonly) */ getConfig(): Readonly<Required<ClientConfig>> { return { ...this.config } } /** * Get interceptor manager for advanced usage */ getInterceptors(): InterceptorManager { return this.interceptors } }