UNPKG

@eka-care/patient-ts-sdk

Version:

TypeScript SDK for Trinity Patient Profile Management System

214 lines (213 loc) 7.51 kB
/** * Core HTTP client for Trinity Profiles SDK */ import { NetworkError, TimeoutError, TrinitySDKError, createErrorFromResponse } from './errors'; import { Environment, EnvironmentBaseUrl } from './types'; /** * Core HTTP client class */ export class HttpClient { constructor(config) { this.refreshPromise = null; this.baseUrl = (config.baseUrl || EnvironmentBaseUrl[config.env || Environment.PROD]).replace(/\/$/, ''); // Remove trailing slash this.accessToken = config.accessToken || ''; this.timeout = config.timeout || 30000; // 30 seconds default this.config = config; this.onUnauthorized = config.onUnauthorized; } /** * Get current configuration (for token updates) */ getConfig() { return { ...this.config, baseUrl: this.baseUrl, accessToken: this.accessToken, timeout: this.timeout, }; } /** * Update the in-memory access token. Used by the 401 retry path and by * external callers that want to swap the token without rebuilding the client. */ setAccessToken(token) { this.accessToken = token; } /** * Deduped token refresh. Concurrent 401s await the same in-flight promise * so the onUnauthorized callback fires at most once per refresh cycle. * The callback is bounded to 10s; on timeout we resolve to null (the 401 * surfaces as AuthenticationError) and let the next 401 trigger a fresh * attempt. */ getRefreshedToken() { if (!this.onUnauthorized) { return Promise.resolve(null); } if (!this.refreshPromise) { this.refreshPromise = (async () => { const REFRESH_TIMEOUT_MS = 10000; let timeoutId; const timeoutPromise = new Promise((resolve) => { timeoutId = setTimeout(() => resolve(null), REFRESH_TIMEOUT_MS); }); try { return await Promise.race([this.onUnauthorized(), timeoutPromise]); } finally { if (timeoutId) clearTimeout(timeoutId); this.refreshPromise = null; } })(); } return this.refreshPromise; } /** * Make HTTP request. * * On HTTP 401, if an `onUnauthorized` callback was configured, the SDK * invokes it once to obtain a fresh token and retries the request a single * time. HTTP 403 is treated as a hard authorization failure and is NOT * retried — `AuthorizationError` propagates directly to the caller. */ async request(options, isRetry = false) { const url = this.buildUrl(options.path, options.params); const headers = this.buildHeaders(options.headers); headers['client-id'] = 'pt-directory-sdk'; const requestInit = { method: options.method, headers, signal: this.createAbortSignal(), credentials: 'include', }; // Add body for POST/PATCH requests if (options.body && (options.method === 'POST' || options.method === 'PATCH')) { requestInit.body = JSON.stringify(options.body); } try { const response = await fetch(url, requestInit); // Parse response let data; const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { data = (await response.json()); } else { data = (await response.text()); } // Handle error responses if (!response.ok) { // 401 → attempt one token refresh + retry. 403 is intentionally // excluded: an authorization failure is not fixable by a new token. if (response.status === 401 && !isRetry && this.onUnauthorized) { const newToken = await this.getRefreshedToken(); if (newToken) { this.setAccessToken(newToken); return this.request(options, true); } } const errorMessage = this.extractErrorMessage(data); throw createErrorFromResponse(response.status, errorMessage, data); } // Convert headers to plain object const responseHeaders = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); return { data, status: response.status, statusText: response.statusText, headers: responseHeaders, }; } catch (error) { if (error instanceof TrinitySDKError) { throw error; } if (error instanceof TypeError && error.message.includes('fetch')) { throw new NetworkError('Network connection failed'); } if (error instanceof Error && error.name === 'AbortError') { throw new TimeoutError('Request timeout'); } throw new TrinitySDKError(error instanceof Error ? error.message : 'Unknown error occurred'); } } /** * GET request */ async get(path, params) { return this.request({ method: 'GET', path, params }); } /** * POST request */ async post(path, body) { return this.request({ method: 'POST', path, body }); } /** * PATCH request */ async patch(path, body) { return this.request({ method: 'PATCH', path, body }); } /** * DELETE request */ async delete(path) { return this.request({ method: 'DELETE', path }); } /** * Build complete URL with query parameters */ buildUrl(path, params) { const cleanPath = path.startsWith('/') ? path : `/${path}`; let url = `${this.baseUrl}${cleanPath}`; if (params && Object.keys(params).length > 0) { const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { searchParams.append(key, String(value)); }); url += `?${searchParams.toString()}`; } return url; } /** * Build request headers */ buildHeaders(customHeaders) { const headers = { 'Content-Type': 'application/json', Accept: 'application/json', }; if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; } if (customHeaders) { Object.assign(headers, customHeaders); } return headers; } /** * Create abort signal for timeout */ createAbortSignal() { const controller = new AbortController(); setTimeout(() => controller.abort(), this.timeout); return controller.signal; } /** * Extract error message from response */ extractErrorMessage(data) { if (typeof data === 'string') { return data; } if (data && typeof data === 'object') { return data.error || data.message || data.detail || 'API request failed'; } return 'API request failed'; } }