feat: add @manacore/shared-api-client package

Create unified API client for all web apps with:
- createApiClient factory function
- ApiResult<T> Go-style error handling
- HTTP methods: get, post, put, patch, delete, upload
- Auto token handling via getAuthToken callback
- Timeout support with AbortController
- Retry logic with exponential backoff
- Runtime URL injection for Docker
- FormData support for file uploads

Migrate clock app as proof of concept:
- Replace local fetchApi with shared createApiClient
- Update stores to use ApiError.message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 14:19:55 +01:00
parent 2b3210df85
commit e23d1194d8
12 changed files with 562 additions and 292 deletions

View file

@ -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:*",

View file

@ -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<T> {
data?: T;
error?: string;
}
/**
* Clock API client instance
* - Auto token handling via authStore.getValidToken()
* - Consistent ApiResult<T> 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<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
try {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['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: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
put: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
patch: <T>(endpoint: string, body?: unknown) =>
fetchApi<T>(endpoint, {
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
};
// Re-export types for convenience
export type { ApiResult };

View file

@ -47,9 +47,9 @@ export const alarmsStore = {
const response = await api.get<Alarm[]>('/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<Alarm>('/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<Alarm>(`/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);

View file

@ -47,9 +47,9 @@ export const timersStore = {
const response = await api.get<Timer[]>('/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<Timer>('/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<Timer>(`/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<Timer>(`/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<Timer>(`/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<Timer>(`/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);

View file

@ -32,9 +32,9 @@ export const worldClocksStore = {
const response = await api.get<WorldClock[]>('/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<WorldClock>('/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

View file

@ -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"
}
}

View file

@ -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> | 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: <T>(endpoint: string, options?: Omit<FetchOptions, 'method'>) => Promise<ApiResponse<T>>;
/** Make a POST request */
post: <T>(
endpoint: string,
body?: unknown,
options?: Omit<FetchOptions, 'method' | 'body'>
) => Promise<ApiResponse<T>>;
/** Make a PUT request */
put: <T>(
endpoint: string,
body?: unknown,
options?: Omit<FetchOptions, 'method' | 'body'>
) => Promise<ApiResponse<T>>;
/** Make a PATCH request */
patch: <T>(
endpoint: string,
body?: unknown,
options?: Omit<FetchOptions, 'method' | 'body'>
) => Promise<ApiResponse<T>>;
/** Make a DELETE request */
delete: <T>(endpoint: string, options?: Omit<FetchOptions, 'method'>) => Promise<ApiResponse<T>>;
/** Make a request with any method */
request: <T>(endpoint: string, options?: FetchOptions) => Promise<ApiResponse<T>>;
/** Upload a single file */
uploadFile: <T>(endpoint: string, file: File, token?: string) => Promise<ApiResponse<T>>;
/** Upload multiple files */
uploadFiles: <T>(endpoint: string, files: File[], token?: string) => Promise<ApiResponse<T>>;
}
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<User[]>('/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<string | undefined> {
if (providedToken) return providedToken;
/**
* 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;
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<T>(endpoint: string, options: FetchOptions = {}): Promise<ApiResponse<T>> {
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<string, string> = { ...customHeaders };
// Get auth token if not skipping
const headers: Record<string, string> = {
...((init.headers as Record<string, string>) || {}),
...(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<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 };
}
}
async function uploadFile<T>(
endpoint: string,
file: File,
token?: string
): Promise<ApiResponse<T>> {
const authToken = await getAuthToken(token);
try {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
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<T>(
endpoint: string,
files: File[],
token?: string
): Promise<ApiResponse<T>> {
const authToken = await getAuthToken(token);
try {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const headers: Record<string, string> = {};
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: <T>(endpoint: string, options?: Omit<FetchOptions, 'method'>) =>
request<T>(endpoint, { ...options, method: 'GET' }),
post: <T>(endpoint: string, body?: unknown, options?: Omit<FetchOptions, 'method' | 'body'>) =>
request<T>(endpoint, { ...options, method: 'POST', body }),
put: <T>(endpoint: string, body?: unknown, options?: Omit<FetchOptions, 'method' | 'body'>) =>
request<T>(endpoint, { ...options, method: 'PUT', body }),
patch: <T>(endpoint: string, body?: unknown, options?: Omit<FetchOptions, 'method' | 'body'>) =>
request<T>(endpoint, { ...options, method: 'PATCH', body }),
delete: <T>(endpoint: string, options?: Omit<FetchOptions, 'method'>) =>
request<T>(endpoint, { ...options, method: 'DELETE' }),
request,
uploadFile,
uploadFiles,
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
);
},
};
}

View file

@ -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<User[]>('/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';

View file

@ -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<string, string>;
}
export interface ApiResponse<T> {
/**
* Result wrapper for API responses
* Provides explicit success/error handling without try/catch
*/
export interface ApiResult<T> {
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<string | null>;
/** 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<string, string>;
/** Override timeout for this request */
timeout?: number;
/** Skip authentication for this request */
skipAuth?: boolean;
/** Query parameters to append to URL */
params?: Record<string, string | number | boolean | undefined>;
/** Override retry count for this request */
retries?: number;
}
/**
* API client interface with HTTP methods
*/
export interface ApiClient {
/** GET request */
get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResult<T>>;
/** POST request */
post<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResult<T>>;
/** PUT request */
put<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResult<T>>;
/** PATCH request */
patch<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResult<T>>;
/** DELETE request */
delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResult<T>>;
/** Upload file(s) with FormData */
upload<T>(endpoint: string, formData: FormData, options?: RequestOptions): Promise<ApiResult<T>>;
}

View file

@ -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, string | number | boolean | undefined>
): 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<string> {
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<void> {
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<string, unknown>).__PUBLIC_BACKEND_URL__;
if (typeof runtimeUrl === 'string' && runtimeUrl) {
return runtimeUrl;
}
}
return configuredUrl;
}

View file

@ -6,6 +6,7 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",

5
pnpm-lock.yaml generated
View file

@ -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: