diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index 617b8d6ad..578b0bcbe 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -30,6 +30,7 @@ "vite": "^6.0.0" }, "dependencies": { + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", diff --git a/apps/contacts/apps/web/src/lib/api/client.ts b/apps/contacts/apps/web/src/lib/api/client.ts index 750ed1668..64238399d 100644 --- a/apps/contacts/apps/web/src/lib/api/client.ts +++ b/apps/contacts/apps/web/src/lib/api/client.ts @@ -1,71 +1,80 @@ /** - * Centralized API client with authentication + * API Client for Contacts 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'; -import { API_BASE } from './config'; +import { PUBLIC_BACKEND_URL } from '$env/static/public'; + +const API_URL = PUBLIC_BACKEND_URL || 'http://localhost:3015'; /** - * Make an authenticated API request - * @param url API endpoint (will be prefixed with API_BASE) - * @param options Fetch options - * @returns Parsed JSON response + * Contacts API client instance + * - Auto token handling via authStore.getValidToken() + * - Consistent ApiResult response format + */ +export const api = createApiClient({ + baseUrl: API_URL, + apiPrefix: '/api/v1', + getAuthToken: () => authStore.getValidToken(), + timeout: 30000, + debug: import.meta.env.DEV, +}); + +/** + * Legacy fetchWithAuth wrapper for backward compatibility + * Converts ApiResult to throw-based pattern */ export async function fetchWithAuth( url: string, options: RequestInit = {} ): Promise { - const token = await authStore.getAccessToken(); + const method = options.method || 'GET'; + const body = options.body ? JSON.parse(options.body as string) : undefined; - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }; - - if (token) { - (headers as Record)['Authorization'] = `Bearer ${token}`; + let result: ApiResult; + switch (method) { + case 'POST': + result = await api.post(url, body); + break; + case 'PUT': + result = await api.put(url, body); + break; + case 'PATCH': + result = await api.patch(url, body); + break; + case 'DELETE': + result = await api.delete(url); + break; + default: + result = await api.get(url); } - const response = await fetch(`${API_BASE}${url}`, { - ...options, - headers, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })); - throw new Error(error.message || 'Request failed'); + if (result.error) { + throw new Error(result.error.message); } - return response.json(); + return result.data as T; } /** - * Make an authenticated API request without JSON content type - * Used for file uploads (FormData) + * Legacy fetchWithAuthFormData for file uploads + * Uses the shared API client's upload method */ export async function fetchWithAuthFormData( url: string, options: RequestInit = {} ): Promise { - const token = await authStore.getAccessToken(); + const formData = options.body as FormData; + const result = await api.upload(url, formData); - const headers: HeadersInit = { - ...(options.headers || {}), - }; - - if (token) { - (headers as Record)['Authorization'] = `Bearer ${token}`; + if (result.error) { + throw new Error(result.error.message); } - const response = await fetch(`${API_BASE}${url}`, { - ...options, - headers, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })); - throw new Error(error.message || 'Request failed'); - } - - return response.json(); + return result.data as T; } + +// Re-export types for convenience +export type { ApiResult }; diff --git a/apps/storage/apps/web/package.json b/apps/storage/apps/web/package.json index 61b9b74f2..789ac2d0c 100644 --- a/apps/storage/apps/web/package.json +++ b/apps/storage/apps/web/package.json @@ -27,6 +27,7 @@ "vite": "^6.0.0" }, "dependencies": { + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", diff --git a/apps/storage/apps/web/src/lib/api/client.ts b/apps/storage/apps/web/src/lib/api/client.ts index 607c35dda..9ebd5e58c 100644 --- a/apps/storage/apps/web/src/lib/api/client.ts +++ b/apps/storage/apps/web/src/lib/api/client.ts @@ -1,48 +1,68 @@ /** * API Client for Storage 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_BASE_URL = 'http://localhost:3016/api/v1'; +const API_URL = 'http://localhost:3016'; +/** + * Storage API client instance + * - Auto token handling via authStore.getValidToken() + * - Consistent ApiResult response format + */ +const api = createApiClient({ + baseUrl: API_URL, + apiPrefix: '/api/v1', + getAuthToken: () => authStore.getAccessToken(), + timeout: 30000, + debug: import.meta.env.DEV, +}); + +// Legacy type alias for backward compatibility export interface ApiResponse { data?: T; error?: string; } -async function getHeaders(): Promise { - const token = await authStore.getAccessToken(); - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; +/** + * Convert ApiResult to legacy ApiResponse format + */ +function toLegacyResponse(result: ApiResult): ApiResponse { + if (result.error) { + return { error: result.error.message }; } - return headers; + return { data: result.data ?? undefined }; } +/** + * Legacy request wrapper for backward compatibility + */ async function request(endpoint: string, options: RequestInit = {}): Promise> { - try { - const headers = await getHeaders(); - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - ...options, - headers: { - ...headers, - ...(options.headers || {}), - }, - }); + const method = options.method || 'GET'; + const body = options.body ? JSON.parse(options.body as string) : undefined; - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `HTTP ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - return { error: error instanceof Error ? error.message : 'Unknown error' }; + let result: ApiResult; + switch (method) { + case 'POST': + result = await api.post(endpoint, body); + break; + case 'PUT': + result = await api.put(endpoint, body); + break; + case 'PATCH': + result = await api.patch(endpoint, body); + break; + case 'DELETE': + result = await api.delete(endpoint); + break; + default: + result = await api.get(endpoint); } + + return toLegacyResponse(result); } // File Types @@ -112,38 +132,20 @@ export const filesApi = { get: (id: string) => request(`/files/${id}`), upload: async (file: File, folderId?: string): Promise> => { - const token = await authStore.getAccessToken(); const formData = new FormData(); formData.append('file', file); if (folderId) { formData.append('parentFolderId', folderId); } - try { - const response = await fetch(`${API_BASE_URL}/files/upload`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, - body: formData, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { error: errorData.message || `HTTP ${response.status}` }; - } - - const data = await response.json(); - return { data }; - } catch (error) { - return { error: error instanceof Error ? error.message : 'Unknown error' }; - } + const result = await api.upload('/files/upload', formData); + return toLegacyResponse(result); }, download: async (id: string): Promise => { const token = await authStore.getAccessToken(); try { - const response = await fetch(`${API_BASE_URL}/files/${id}/download`, { + const response = await fetch(`${API_URL}/api/v1/files/${id}/download`, { headers: { Authorization: `Bearer ${token}`, }, diff --git a/apps/todo/apps/web/package.json b/apps/todo/apps/web/package.json index 75d8298cc..ff4b7f56a 100644 --- a/apps/todo/apps/web/package.json +++ b/apps/todo/apps/web/package.json @@ -30,6 +30,7 @@ "vite": "^6.0.0" }, "dependencies": { + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-splitscreen": "workspace:*", "@manacore/shared-types": "workspace:*", diff --git a/apps/todo/apps/web/src/lib/api/client.ts b/apps/todo/apps/web/src/lib/api/client.ts index eb4e6ed00..a454d14eb 100644 --- a/apps/todo/apps/web/src/lib/api/client.ts +++ b/apps/todo/apps/web/src/lib/api/client.ts @@ -1,106 +1,89 @@ -import { browser } from '$app/environment'; +/** + * API Client for Todo backend + * Uses @manacore/shared-api-client for consistent error handling + */ + +import { createApiClient, type ApiResult } from '@manacore/shared-api-client'; import { PUBLIC_BACKEND_URL } from '$env/static/public'; -interface ApiOptions { - method?: string; - body?: unknown; - headers?: Record; +const API_URL = PUBLIC_BACKEND_URL || 'http://localhost:3018'; + +// Token storage for manual token management (legacy pattern) +let currentToken: string | null = null; + +/** + * Todo API client instance + * - Supports manual token setting via setAccessToken() + * - Consistent ApiResult response format + * - Runtime URL injection for Docker + */ +export const api = createApiClient({ + baseUrl: API_URL, + apiPrefix: '', + getAuthToken: async () => currentToken, + timeout: 30000, + debug: import.meta.env.DEV, +}); + +/** + * Legacy token management functions + * Used by auth store to set token after login + */ +export function setAccessToken(token: string | null) { + currentToken = token; } -interface ApiError { - message: string; - statusCode: number; +export function getAccessToken(): string | null { + return currentToken; } /** - * Get the backend URL, preferring runtime-injected value in browser - * This allows Docker to inject PUBLIC_BACKEND_URL_CLIENT at runtime - * instead of using the build-time PUBLIC_BACKEND_URL + * Wrapper for legacy code that expects throws instead of ApiResult + * Converts ApiResult to throw-based pattern for backward compatibility */ -function getBackendUrl(): string { - if (browser && typeof window !== 'undefined') { - const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string }) - .__PUBLIC_BACKEND_URL__; - if (runtimeUrl) { - return runtimeUrl; - } +export async function fetchApi( + endpoint: string, + options: { method?: string; body?: unknown } = {} +): Promise { + const { method = 'GET', body } = options; + + let result: ApiResult; + switch (method) { + case 'POST': + result = await api.post(endpoint, body); + break; + case 'PUT': + result = await api.put(endpoint, body); + break; + case 'PATCH': + result = await api.patch(endpoint, body); + break; + case 'DELETE': + result = await api.delete(endpoint); + break; + default: + result = await api.get(endpoint); } - return PUBLIC_BACKEND_URL || 'http://localhost:3018'; + + if (result.error) { + throw new Error(result.error.message); + } + + return result.data as T; } -class ApiClient { - private accessToken: string | null = null; +/** + * Legacy apiClient wrapper for backward compatibility + */ +export const apiClient = { + setAccessToken, + getAccessToken, + get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }), + post: (endpoint: string, body?: unknown) => fetchApi(endpoint, { method: 'POST', body }), + put: (endpoint: string, body?: unknown) => fetchApi(endpoint, { method: 'PUT', body }), + patch: (endpoint: string, body?: unknown) => fetchApi(endpoint, { method: 'PATCH', body }), + delete: (endpoint: string) => fetchApi(endpoint, { method: 'DELETE' }), +}; - // Use getter to evaluate URL at request time (browser may hydrate after construction) - private get baseUrl(): string { - return getBackendUrl(); - } - - setAccessToken(token: string | null) { - this.accessToken = token; - } - - getAccessToken(): string | null { - return this.accessToken; - } - - async fetch(endpoint: string, options: ApiOptions = {}): Promise { - const { method = 'GET', body, headers = {} } = options; - - const requestHeaders: Record = { - 'Content-Type': 'application/json', - ...headers, - }; - - if (this.accessToken) { - requestHeaders['Authorization'] = `Bearer ${this.accessToken}`; - } - - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method, - headers: requestHeaders, - body: body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - let errorMessage = 'An error occurred'; - try { - const errorData = (await response.json()) as ApiError; - errorMessage = errorData.message || errorMessage; - } catch { - errorMessage = response.statusText || errorMessage; - } - throw new Error(errorMessage); - } - - // Handle 204 No Content - if (response.status === 204) { - return {} as T; - } - - return response.json() as Promise; - } - - // Convenience methods - get(endpoint: string, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'GET', headers }); - } - - post(endpoint: string, body?: unknown, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'POST', body, headers }); - } - - put(endpoint: string, body?: unknown, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'PUT', body, headers }); - } - - patch(endpoint: string, body?: unknown, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'PATCH', body, headers }); - } - - delete(endpoint: string, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'DELETE', headers }); - } -} - -export const apiClient = new ApiClient(); +// Re-export types for convenience +export type { ApiResult }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1538cbfe8..971e84d25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1085,6 +1085,9 @@ importers: apps/contacts/apps/web: dependencies: + '@manacore/shared-api-client': + specifier: workspace:* + version: link:../../../../packages/shared-api-client '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth @@ -3866,6 +3869,9 @@ importers: apps/storage/apps/web: dependencies: + '@manacore/shared-api-client': + specifier: workspace:* + version: link:../../../../packages/shared-api-client '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth @@ -4094,6 +4100,9 @@ importers: apps/todo/apps/web: dependencies: + '@manacore/shared-api-client': + specifier: workspace:* + version: link:../../../../packages/shared-api-client '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth