diff --git a/apps/clock/apps/web/package.json b/apps/clock/apps/web/package.json index f766a4d51..25d7b80ed 100644 --- a/apps/clock/apps/web/package.json +++ b/apps/clock/apps/web/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@clock/shared": "workspace:*", + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", diff --git a/apps/clock/apps/web/src/lib/api/client.ts b/apps/clock/apps/web/src/lib/api/client.ts index bcf5cbd13..798c1b214 100644 --- a/apps/clock/apps/web/src/lib/api/client.ts +++ b/apps/clock/apps/web/src/lib/api/client.ts @@ -1,80 +1,26 @@ /** * API Client for Clock backend + * Uses @manacore/shared-api-client for consistent error handling */ +import { createApiClient, type ApiResult } from '@manacore/shared-api-client'; import { authStore } from '$lib/stores/auth.svelte'; -const API_URL = 'http://localhost:3017/api/v1'; +const API_URL = 'http://localhost:3017'; -export interface ApiResponse { - data?: T; - error?: string; -} +/** + * Clock API client instance + * - Auto token handling via authStore.getValidToken() + * - Consistent ApiResult response format + * - Automatic retry on server errors (configurable) + */ +export const api = createApiClient({ + baseUrl: API_URL, + apiPrefix: '/api/v1', + getAuthToken: () => authStore.getValidToken(), + timeout: 30000, + debug: import.meta.env.DEV, +}); -export async function fetchApi( - endpoint: string, - options: RequestInit = {} -): Promise> { - try { - const token = await authStore.getAccessToken(); - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }; - - if (token) { - (headers as Record)['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_URL}${endpoint}`, { - ...options, - headers, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - error: errorData.message || `HTTP error ${response.status}`, - }; - } - - // Handle 204 No Content - if (response.status === 204) { - return { data: undefined as T }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - console.error('API Error:', error); - return { - error: error instanceof Error ? error.message : 'Network error', - }; - } -} - -// Convenience methods -export const api = { - get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), - - post: (endpoint: string, body?: unknown) => - fetchApi(endpoint, { - method: 'POST', - body: body ? JSON.stringify(body) : undefined, - }), - - put: (endpoint: string, body?: unknown) => - fetchApi(endpoint, { - method: 'PUT', - body: body ? JSON.stringify(body) : undefined, - }), - - patch: (endpoint: string, body?: unknown) => - fetchApi(endpoint, { - method: 'PATCH', - body: body ? JSON.stringify(body) : undefined, - }), - - delete: (endpoint: string) => fetchApi(endpoint, { method: 'DELETE' }), -}; +// Re-export types for convenience +export type { ApiResult }; diff --git a/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts b/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts index 5feef5fe6..209ce26a3 100644 --- a/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/alarms.svelte.ts @@ -47,9 +47,9 @@ export const alarmsStore = { const response = await api.get('/alarms'); if (response.error) { - error = response.error; + error = response.error.message; loading = false; - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } alarms = response.data || []; @@ -73,7 +73,7 @@ export const alarmsStore = { const response = await api.post('/alarms', input); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } if (response.data) { @@ -101,7 +101,7 @@ export const alarmsStore = { const response = await api.patch(`/alarms/${id}`, input); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } if (response.data) { @@ -136,7 +136,7 @@ export const alarmsStore = { const response = await api.delete(`/alarms/${id}`); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } alarms = alarms.filter((a) => a.id !== id); diff --git a/apps/clock/apps/web/src/lib/stores/timers.svelte.ts b/apps/clock/apps/web/src/lib/stores/timers.svelte.ts index ef8f7331c..3da6805de 100644 --- a/apps/clock/apps/web/src/lib/stores/timers.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/timers.svelte.ts @@ -47,9 +47,9 @@ export const timersStore = { const response = await api.get('/timers'); if (response.error) { - error = response.error; + error = response.error.message; loading = false; - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } timers = response.data || []; @@ -73,7 +73,7 @@ export const timersStore = { const response = await api.post('/timers', input); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } if (response.data) { @@ -101,7 +101,7 @@ export const timersStore = { const response = await api.patch(`/timers/${id}`, input); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } if (response.data) { @@ -129,7 +129,7 @@ export const timersStore = { const response = await api.post(`/timers/${id}/start`); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } if (response.data) { @@ -157,7 +157,7 @@ export const timersStore = { const response = await api.post(`/timers/${id}/pause`); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } if (response.data) { @@ -185,7 +185,7 @@ export const timersStore = { const response = await api.post(`/timers/${id}/reset`); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } if (response.data) { @@ -210,7 +210,7 @@ export const timersStore = { const response = await api.delete(`/timers/${id}`); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } timers = timers.filter((t) => t.id !== id); diff --git a/apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts b/apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts index 05942bcfc..757d8fbfd 100644 --- a/apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts @@ -32,9 +32,9 @@ export const worldClocksStore = { const response = await api.get('/world-clocks'); if (response.error) { - error = response.error; + error = response.error.message; loading = false; - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } worldClocks = response.data || []; @@ -49,7 +49,7 @@ export const worldClocksStore = { const response = await api.post('/world-clocks', input); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } if (response.data) { @@ -65,7 +65,7 @@ export const worldClocksStore = { const response = await api.delete(`/world-clocks/${id}`); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } worldClocks = worldClocks.filter((wc) => wc.id !== id); @@ -79,7 +79,7 @@ export const worldClocksStore = { const response = await api.put('/world-clocks/reorder', { ids }); if (response.error) { - return { success: false, error: response.error }; + return { success: false, error: response.error.message }; } // Update local order diff --git a/packages/shared-api-client/package.json b/packages/shared-api-client/package.json index 8bd95a6ea..d55304d8f 100644 --- a/packages/shared-api-client/package.json +++ b/packages/shared-api-client/package.json @@ -6,12 +6,16 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } }, "scripts": { + "build": "tsc", "type-check": "tsc --noEmit" }, "devDependencies": { - "typescript": "^5.0.0" + "typescript": "^5.9.3" } } diff --git a/packages/shared-api-client/src/client.ts b/packages/shared-api-client/src/client.ts index a8d93cfc5..0af2f3506 100644 --- a/packages/shared-api-client/src/client.ts +++ b/packages/shared-api-client/src/client.ts @@ -1,218 +1,305 @@ /** - * Shared API Client Factory - * Creates a configured API client for making authenticated requests. + * API Client Factory + * Creates a configured API client with consistent error handling */ -import type { ApiResponse, FetchOptions } from './types'; +import type { ApiClient, ApiClientConfig, ApiResult, RequestOptions } from './types'; +import { + buildQueryString, + createApiError, + getBaseUrl, + getErrorCodeFromStatus, + isRetryableError, + parseErrorResponse, + sleep, +} from './utils'; -export interface ApiClientConfig { - /** Base URL for the API (e.g., 'http://localhost:3002') */ - baseUrl: string; - /** Optional API prefix (default: '/api') */ - apiPrefix?: string; - /** Function to get the current auth token */ - getToken?: () => Promise | string | null; - /** Whether running in browser environment */ - isBrowser?: boolean; - /** Local storage key for token fallback */ - tokenStorageKey?: string; -} - -export interface ApiClient { - /** Make a GET request */ - get: (endpoint: string, options?: Omit) => Promise>; - /** Make a POST request */ - post: ( - endpoint: string, - body?: unknown, - options?: Omit - ) => Promise>; - /** Make a PUT request */ - put: ( - endpoint: string, - body?: unknown, - options?: Omit - ) => Promise>; - /** Make a PATCH request */ - patch: ( - endpoint: string, - body?: unknown, - options?: Omit - ) => Promise>; - /** Make a DELETE request */ - delete: (endpoint: string, options?: Omit) => Promise>; - /** Make a request with any method */ - request: (endpoint: string, options?: FetchOptions) => Promise>; - /** Upload a single file */ - uploadFile: (endpoint: string, file: File, token?: string) => Promise>; - /** Upload multiple files */ - uploadFiles: (endpoint: string, files: File[], token?: string) => Promise>; -} +const DEFAULT_TIMEOUT = 30000; +const DEFAULT_RETRIES = 0; +const DEFAULT_RETRY_DELAY = 1000; /** - * Create an API client with the given configuration. + * 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('/users'); + * if (error) { + * console.error('Failed:', error.message); + * return; + * } + * // data is typed as User[] + * ``` */ export function createApiClient(config: ApiClientConfig): ApiClient { - const { baseUrl, apiPrefix = '/api', getToken, isBrowser = true, tokenStorageKey } = config; + const { + apiPrefix = '', + getAuthToken, + timeout = DEFAULT_TIMEOUT, + retries = DEFAULT_RETRIES, + retryDelay = DEFAULT_RETRY_DELAY, + onError, + debug = false, + } = config; - async function getAuthToken(providedToken?: string): Promise { - if (providedToken) return providedToken; + /** + * Internal fetch with error handling, timeout, and retries + */ + async function fetchWithRetry( + endpoint: string, + init: RequestInit, + options: RequestOptions = {}, + attemptNum = 0 + ): Promise> { + 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; - if (getToken) { - const token = await getToken(); - if (token) return token; - } - - // Fallback to localStorage if in browser and key provided - if (isBrowser && tokenStorageKey && typeof localStorage !== 'undefined') { - return localStorage.getItem(tokenStorageKey) || undefined; - } - - return undefined; - } - - async function request(endpoint: string, options: FetchOptions = {}): Promise> { - const { method = 'GET', body, token, isFormData = false, headers: customHeaders } = options; - - const authToken = await getAuthToken(token); + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), requestTimeout); try { - const headers: Record = { ...customHeaders }; + // Get auth token if not skipping + const headers: Record = { + ...((init.headers as Record) || {}), + ...(options.headers || {}), + }; - // Don't set Content-Type for FormData - browser sets it automatically with boundary - if (!isFormData) { - headers['Content-Type'] = 'application/json'; + if (!options.skipAuth && getAuthToken) { + const token = await getAuthToken(); + if (token) { + headers['Authorization'] = 'Bearer ' + token; + } } - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; + if (debug) { + console.log('[API] ' + init.method + ' ' + url); } - const url = `${baseUrl}${apiPrefix}${endpoint}`; const response = await fetch(url, { - method, + ...init, headers, - body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, + signal: controller.signal, }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: new Error(errorData.message || `API error: ${response.status}`), - }; - } + clearTimeout(timeoutId); - // Handle empty responses (204 No Content) + // Handle 204 No Content if (response.status === 204) { - return { data: null, error: null }; + return { data: null as T, error: null }; } - const data = await response.json(); - return { data, error: null }; - } catch (error) { - return { - data: null, - error: error instanceof Error ? error : new Error('Unknown error'), - }; + // 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(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(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(endpoint, init, options, attemptNum + 1); + } + + if (onError) { + onError(error, endpoint); + } + return { data: null, error }; } } - async function uploadFile( - endpoint: string, - file: File, - token?: string - ): Promise> { - const authToken = await getAuthToken(token); - - try { - const formData = new FormData(); - formData.append('file', file); - - const headers: Record = {}; - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, { - method: 'POST', - headers, - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: new Error(errorData.message || `Upload error: ${response.status}`), - }; - } - - const data = await response.json(); - return { data, error: null }; - } catch (error) { - return { - data: null, - error: error instanceof Error ? error : new Error('Upload failed'), - }; + /** + * Prepare request body and headers + */ + function prepareBody(body: unknown): { body?: string; contentType?: string } { + if (body === undefined || body === null) { + return {}; } - } - async function uploadFiles( - endpoint: string, - files: File[], - token?: string - ): Promise> { - const authToken = await getAuthToken(token); - - try { - const formData = new FormData(); - files.forEach((file) => { - formData.append('files', file); - }); - - const headers: Record = {}; - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, { - method: 'POST', - headers, - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: new Error(errorData.message || `Upload error: ${response.status}`), - }; - } - - const data = await response.json(); - return { data, error: null }; - } catch (error) { - return { - data: null, - error: error instanceof Error ? error : new Error('Upload failed'), - }; + if (body instanceof FormData) { + // Don't set Content-Type for FormData - browser handles it + return {}; } + + return { + body: JSON.stringify(body), + contentType: 'application/json', + }; } return { - get: (endpoint: string, options?: Omit) => - request(endpoint, { ...options, method: 'GET' }), - post: (endpoint: string, body?: unknown, options?: Omit) => - request(endpoint, { ...options, method: 'POST', body }), - put: (endpoint: string, body?: unknown, options?: Omit) => - request(endpoint, { ...options, method: 'PUT', body }), - patch: (endpoint: string, body?: unknown, options?: Omit) => - request(endpoint, { ...options, method: 'PATCH', body }), - delete: (endpoint: string, options?: Omit) => - request(endpoint, { ...options, method: 'DELETE' }), - request, - uploadFile, - uploadFiles, + async get(endpoint: string, options?: RequestOptions): Promise> { + return fetchWithRetry( + endpoint, + { + method: 'GET', + headers: { Accept: 'application/json' }, + }, + options + ); + }, + + async post( + endpoint: string, + body?: unknown, + options?: RequestOptions + ): Promise> { + const { body: jsonBody, contentType } = prepareBody(body); + return fetchWithRetry( + endpoint, + { + method: 'POST', + headers: { + Accept: 'application/json', + ...(contentType ? { 'Content-Type': contentType } : {}), + }, + body: jsonBody, + }, + options + ); + }, + + async put( + endpoint: string, + body?: unknown, + options?: RequestOptions + ): Promise> { + const { body: jsonBody, contentType } = prepareBody(body); + return fetchWithRetry( + endpoint, + { + method: 'PUT', + headers: { + Accept: 'application/json', + ...(contentType ? { 'Content-Type': contentType } : {}), + }, + body: jsonBody, + }, + options + ); + }, + + async patch( + endpoint: string, + body?: unknown, + options?: RequestOptions + ): Promise> { + const { body: jsonBody, contentType } = prepareBody(body); + return fetchWithRetry( + endpoint, + { + method: 'PATCH', + headers: { + Accept: 'application/json', + ...(contentType ? { 'Content-Type': contentType } : {}), + }, + body: jsonBody, + }, + options + ); + }, + + async delete(endpoint: string, options?: RequestOptions): Promise> { + return fetchWithRetry( + endpoint, + { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }, + options + ); + }, + + async upload( + endpoint: string, + formData: FormData, + options?: RequestOptions + ): Promise> { + return fetchWithRetry( + endpoint, + { + method: 'POST', + // Don't set Content-Type - browser handles multipart boundary + headers: { Accept: 'application/json' }, + body: formData, + }, + options + ); + }, }; } diff --git a/packages/shared-api-client/src/index.ts b/packages/shared-api-client/src/index.ts index f1f3e15a8..ebc33ef43 100644 --- a/packages/shared-api-client/src/index.ts +++ b/packages/shared-api-client/src/index.ts @@ -1,7 +1,51 @@ /** - * Shared API Client for ManaCore Apps - * Provides a unified way to make API calls with authentication. + * @manacore/shared-api-client + * + * Unified API client for all ManaCore web applications. + * Provides consistent error handling, token management, and retry logic. + * + * @example + * ```typescript + * import { createApiClient } from '@manacore/shared-api-client'; + * import { authStore } from '$lib/stores/auth.svelte'; + * + * // Create client instance + * export const api = createApiClient({ + * baseUrl: 'http://localhost:3014', + * apiPrefix: '/api/v1', + * getAuthToken: () => authStore.getValidToken(), + * timeout: 30000, + * retries: 2, + * }); + * + * // Make requests + * const { data, error } = await api.get('/users'); + * + * if (error) { + * if (error.code === 'UNAUTHORIZED') { + * // Handle auth error + * } + * console.error('API Error:', error.message); + * return; + * } + * + * // data is typed as User[] + * console.log('Users:', data); + * ``` */ -export { createApiClient, type ApiClientConfig, type ApiClient } from './client'; -export { type ApiResponse, type FetchOptions, type HttpMethod } from './types'; +// Client factory +export { createApiClient } from './client'; + +// Types +export type { + ApiClient, + ApiClientConfig, + ApiError, + ApiErrorCode, + ApiResult, + RequestOptions, +} from './types'; + +// Utilities +export { buildQueryString, getBaseUrl } from './utils'; diff --git a/packages/shared-api-client/src/types.ts b/packages/shared-api-client/src/types.ts index 3fca6d913..1d39edfa7 100644 --- a/packages/shared-api-client/src/types.ts +++ b/packages/shared-api-client/src/types.ts @@ -1,18 +1,108 @@ /** - * Shared API Client Types + * API Client Types + * Go-style Result pattern for consistent error handling */ -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - -export interface FetchOptions { - method?: HttpMethod; - body?: unknown; - token?: string; - isFormData?: boolean; - headers?: Record; -} - -export interface ApiResponse { +/** + * Result wrapper for API responses + * Provides explicit success/error handling without try/catch + */ +export interface ApiResult { data: T | null; - error: Error | null; + error: ApiError | null; +} + +/** + * Structured API error with type information + */ +export interface ApiError { + message: string; + code: ApiErrorCode; + status?: number; + details?: unknown; +} + +/** + * Error codes for different failure scenarios + */ +export type ApiErrorCode = + | 'NETWORK_ERROR' + | 'TIMEOUT' + | 'UNAUTHORIZED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'VALIDATION_ERROR' + | 'SERVER_ERROR' + | 'UNKNOWN'; + +/** + * Configuration for creating an API client + */ +export interface ApiClientConfig { + /** Base URL for API requests (e.g., 'http://localhost:3014') */ + baseUrl: string; + + /** API prefix to prepend to all endpoints (e.g., '/api/v1') */ + apiPrefix?: string; + + /** Async function to get the current auth token (supports auto-refresh) */ + getAuthToken?: () => Promise; + + /** Request timeout in milliseconds (default: 30000) */ + timeout?: number; + + /** Number of retry attempts for failed requests (default: 0) */ + retries?: number; + + /** Delay between retries in milliseconds (default: 1000) */ + retryDelay?: number; + + /** Custom error handler for logging/reporting */ + onError?: (error: ApiError, endpoint: string) => void; + + /** Enable debug logging (default: false) */ + debug?: boolean; +} + +/** + * Options for individual requests + */ +export interface RequestOptions { + /** Custom headers to merge with defaults */ + headers?: Record; + + /** Override timeout for this request */ + timeout?: number; + + /** Skip authentication for this request */ + skipAuth?: boolean; + + /** Query parameters to append to URL */ + params?: Record; + + /** Override retry count for this request */ + retries?: number; +} + +/** + * API client interface with HTTP methods + */ +export interface ApiClient { + /** GET request */ + get(endpoint: string, options?: RequestOptions): Promise>; + + /** POST request */ + post(endpoint: string, body?: unknown, options?: RequestOptions): Promise>; + + /** PUT request */ + put(endpoint: string, body?: unknown, options?: RequestOptions): Promise>; + + /** PATCH request */ + patch(endpoint: string, body?: unknown, options?: RequestOptions): Promise>; + + /** DELETE request */ + delete(endpoint: string, options?: RequestOptions): Promise>; + + /** Upload file(s) with FormData */ + upload(endpoint: string, formData: FormData, options?: RequestOptions): Promise>; } diff --git a/packages/shared-api-client/src/utils.ts b/packages/shared-api-client/src/utils.ts new file mode 100644 index 000000000..413a88903 --- /dev/null +++ b/packages/shared-api-client/src/utils.ts @@ -0,0 +1,94 @@ +/** + * API Client Utilities + */ + +import type { ApiError, ApiErrorCode } from './types'; + +/** + * Build a query string from parameters object + * Handles undefined values and proper encoding + */ +export function buildQueryString( + params: Record +): string { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') { + searchParams.append(key, String(value)); + } + } + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ''; +} + +/** + * Determine error code from HTTP status + */ +export function getErrorCodeFromStatus(status: number): ApiErrorCode { + if (status === 401) return 'UNAUTHORIZED'; + if (status === 403) return 'FORBIDDEN'; + if (status === 404) return 'NOT_FOUND'; + if (status === 422 || status === 400) return 'VALIDATION_ERROR'; + if (status >= 500) return 'SERVER_ERROR'; + return 'UNKNOWN'; +} + +/** + * Create a standardized API error + */ +export function createApiError( + message: string, + code: ApiErrorCode, + status?: number, + details?: unknown +): ApiError { + return { message, code, status, details }; +} + +/** + * Parse error response body + */ +export async function parseErrorResponse(response: Response): Promise { + try { + const data = await response.json(); + return data.message || data.error || JSON.stringify(data); + } catch { + return response.statusText || 'Unknown error'; + } +} + +/** + * Sleep utility for retry delays + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if error is retryable (network issues, 5xx errors) + */ +export function isRetryableError(error: ApiError): boolean { + if (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') { + return true; + } + if (error.status && error.status >= 500) { + return true; + } + return false; +} + +/** + * Get base URL with runtime injection support for Docker + * Checks window.__PUBLIC_BACKEND_URL__ first, then falls back to provided URL + */ +export function getBaseUrl(configuredUrl: string): string { + if (typeof window !== 'undefined') { + const runtimeUrl = (window as unknown as Record).__PUBLIC_BACKEND_URL__; + if (typeof runtimeUrl === 'string' && runtimeUrl) { + return runtimeUrl; + } + } + return configuredUrl; +} diff --git a/packages/shared-api-client/tsconfig.json b/packages/shared-api-client/tsconfig.json index c0db43203..916f65a1b 100644 --- a/packages/shared-api-client/tsconfig.json +++ b/packages/shared-api-client/tsconfig.json @@ -6,6 +6,7 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "outDir": "./dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f52ab8b8..1538cbfe8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -855,6 +855,9 @@ importers: '@clock/shared': specifier: workspace:* version: link:../../packages/shared + '@manacore/shared-api-client': + specifier: workspace:* + version: link:../../../../packages/shared-api-client '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth @@ -4435,7 +4438,7 @@ importers: packages/shared-api-client: devDependencies: typescript: - specifier: ^5.0.0 + specifier: ^5.9.3 version: 5.9.3 packages/shared-auth: