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