managarten/packages/shared-api-client/src/client.ts
Till-JS 1348fca84d ♻️ refactor: remove duplicated code (Quick Wins)
- Delete unused Input.svelte from Picture app (70 LOC)
- Remove sleep() from shared-api-client, import from shared-utils
- Fix NodeJS.Timeout type for browser compatibility

Part of consolidation effort - see docs/CONSOLIDATION_OPPORTUNITIES.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:19:18 +01:00

305 lines
7.2 KiB
TypeScript

/**
* API Client Factory
* Creates a configured API client with consistent error handling
*/
import type { ApiClient, ApiClientConfig, ApiResult, RequestOptions } from './types';
import {
buildQueryString,
createApiError,
getBaseUrl,
getErrorCodeFromStatus,
isRetryableError,
parseErrorResponse,
} from './utils';
import { sleep } from '@manacore/shared-utils';
const DEFAULT_TIMEOUT = 30000;
const DEFAULT_RETRIES = 0;
const DEFAULT_RETRY_DELAY = 1000;
/**
* Create a configured API client instance
*
* @example
* ```typescript
* import { createApiClient } from '@manacore/shared-api-client';
* import { authStore } from '$lib/stores/auth.svelte';
*
* export const api = createApiClient({
* baseUrl: 'http://localhost:3014',
* apiPrefix: '/api/v1',
* getAuthToken: () => authStore.getValidToken(),
* });
*
* // Usage
* const { data, error } = await api.get<User[]>('/users');
* if (error) {
* console.error('Failed:', error.message);
* return;
* }
* // data is typed as User[]
* ```
*/
export function createApiClient(config: ApiClientConfig): ApiClient {
const {
apiPrefix = '',
getAuthToken,
timeout = DEFAULT_TIMEOUT,
retries = DEFAULT_RETRIES,
retryDelay = DEFAULT_RETRY_DELAY,
onError,
debug = false,
} = config;
/**
* Internal fetch with error handling, timeout, and retries
*/
async function fetchWithRetry<T>(
endpoint: string,
init: RequestInit,
options: RequestOptions = {},
attemptNum = 0
): Promise<ApiResult<T>> {
const baseUrl = getBaseUrl(config.baseUrl);
const queryString = options.params ? buildQueryString(options.params) : '';
const url = baseUrl + apiPrefix + endpoint + queryString;
const requestTimeout = options.timeout ?? timeout;
const maxRetries = options.retries ?? retries;
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), requestTimeout);
try {
// Get auth token if not skipping
const headers: Record<string, string> = {
...((init.headers as Record<string, string>) || {}),
...(options.headers || {}),
};
if (!options.skipAuth && getAuthToken) {
const token = await getAuthToken();
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
}
if (debug) {
console.log('[API] ' + init.method + ' ' + url);
}
const response = await fetch(url, {
...init,
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Handle 204 No Content
if (response.status === 204) {
return { data: null as T, error: null };
}
// Handle error responses
if (!response.ok) {
const errorMessage = await parseErrorResponse(response);
const error = createApiError(
errorMessage,
getErrorCodeFromStatus(response.status),
response.status
);
// Retry on server errors
if (isRetryableError(error) && attemptNum < maxRetries) {
if (debug) {
console.log('[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' for ' + url);
}
await sleep(retryDelay * (attemptNum + 1)); // Exponential backoff
return fetchWithRetry<T>(endpoint, init, options, attemptNum + 1);
}
if (onError) {
onError(error, endpoint);
}
return { data: null, error };
}
// Parse JSON response
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const data = await response.json();
return { data, error: null };
}
// Handle non-JSON responses (e.g., text, blob)
const text = await response.text();
return { data: text as T, error: null };
} catch (err) {
clearTimeout(timeoutId);
// Handle abort (timeout)
if (err instanceof DOMException && err.name === 'AbortError') {
const error = createApiError('Request timed out after ' + requestTimeout + 'ms', 'TIMEOUT');
if (attemptNum < maxRetries) {
if (debug) {
console.log(
'[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' after timeout for ' + url
);
}
await sleep(retryDelay * (attemptNum + 1));
return fetchWithRetry<T>(endpoint, init, options, attemptNum + 1);
}
if (onError) {
onError(error, endpoint);
}
return { data: null, error };
}
// Handle network errors
const error = createApiError(
err instanceof Error ? err.message : 'Network error',
'NETWORK_ERROR'
);
if (attemptNum < maxRetries) {
if (debug) {
console.log(
'[API] Retry ' + (attemptNum + 1) + '/' + maxRetries + ' after network error for ' + url
);
}
await sleep(retryDelay * (attemptNum + 1));
return fetchWithRetry<T>(endpoint, init, options, attemptNum + 1);
}
if (onError) {
onError(error, endpoint);
}
return { data: null, error };
}
}
/**
* Prepare request body and headers
*/
function prepareBody(body: unknown): { body?: string; contentType?: string } {
if (body === undefined || body === null) {
return {};
}
if (body instanceof FormData) {
// Don't set Content-Type for FormData - browser handles it
return {};
}
return {
body: JSON.stringify(body),
contentType: 'application/json',
};
}
return {
async get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResult<T>> {
return fetchWithRetry<T>(
endpoint,
{
method: 'GET',
headers: { Accept: 'application/json' },
},
options
);
},
async post<T>(
endpoint: string,
body?: unknown,
options?: RequestOptions
): Promise<ApiResult<T>> {
const { body: jsonBody, contentType } = prepareBody(body);
return fetchWithRetry<T>(
endpoint,
{
method: 'POST',
headers: {
Accept: 'application/json',
...(contentType ? { 'Content-Type': contentType } : {}),
},
body: jsonBody,
},
options
);
},
async put<T>(
endpoint: string,
body?: unknown,
options?: RequestOptions
): Promise<ApiResult<T>> {
const { body: jsonBody, contentType } = prepareBody(body);
return fetchWithRetry<T>(
endpoint,
{
method: 'PUT',
headers: {
Accept: 'application/json',
...(contentType ? { 'Content-Type': contentType } : {}),
},
body: jsonBody,
},
options
);
},
async patch<T>(
endpoint: string,
body?: unknown,
options?: RequestOptions
): Promise<ApiResult<T>> {
const { body: jsonBody, contentType } = prepareBody(body);
return fetchWithRetry<T>(
endpoint,
{
method: 'PATCH',
headers: {
Accept: 'application/json',
...(contentType ? { 'Content-Type': contentType } : {}),
},
body: jsonBody,
},
options
);
},
async delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResult<T>> {
return fetchWithRetry<T>(
endpoint,
{
method: 'DELETE',
headers: { Accept: 'application/json' },
},
options
);
},
async upload<T>(
endpoint: string,
formData: FormData,
options?: RequestOptions
): Promise<ApiResult<T>> {
return fetchWithRetry<T>(
endpoint,
{
method: 'POST',
// Don't set Content-Type - browser handles multipart boundary
headers: { Accept: 'application/json' },
body: formData,
},
options
);
},
};
}