mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
✨ 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:
parent
2b3210df85
commit
e23d1194d8
12 changed files with 562 additions and 292 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
|||
94
packages/shared-api-client/src/utils.ts
Normal file
94
packages/shared-api-client/src/utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
|
|
|
|||
5
pnpm-lock.yaml
generated
5
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue