From 1e5175e5220d3076524cc659fe1241b4764d198b Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:32:47 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20migrate=20cale?= =?UTF-8?q?ndar,=20picture,=20nutriphi,=20planta,=20questions,=20skilltree?= =?UTF-8?q?=20to=20shared-api-client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update all web apps to use @manacore/shared-api-client - Remove calendar's local base-client.ts (duplicate of shared package) - Calendar: update todos.ts and birthdays.ts to use shared client - Maintain backward compatibility with existing patterns: - picture: fetchApi, uploadFile, uploadFiles functions - nutriphi: apiClient class with throw-based errors - planta: fetchApi function with {data, error} format - questions/skilltree: apiClient with setAccessToken pattern Co-Authored-By: Claude Opus 4.5 --- apps/calendar/apps/web/package.json | 1 + .../apps/web/src/lib/api/base-client.ts | 119 -------------- .../apps/web/src/lib/api/birthdays.ts | 15 +- apps/calendar/apps/web/src/lib/api/client.ts | 50 +++++- apps/calendar/apps/web/src/lib/api/todos.ts | 40 ++++- apps/nutriphi/apps/web/package.json | 1 + apps/nutriphi/apps/web/src/lib/api/client.ts | 85 +++++----- apps/picture/apps/web/package.json | 1 + apps/picture/apps/web/src/lib/api/client.ts | 153 +++++++----------- apps/planta/apps/web/package.json | 1 + apps/planta/apps/web/src/lib/api/client.ts | 80 ++++----- apps/questions/apps/web/package.json | 1 + apps/questions/apps/web/src/lib/api/client.ts | 108 ++++++------- apps/skilltree/apps/web/package.json | 1 + apps/skilltree/apps/web/src/lib/api/client.ts | 109 ++++++------- pnpm-lock.yaml | 18 +++ 16 files changed, 354 insertions(+), 429 deletions(-) delete mode 100644 apps/calendar/apps/web/src/lib/api/base-client.ts diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index 4d69d2ccc..63d9acda3 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@calendar/shared": "workspace:*", + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-splitscreen": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", diff --git a/apps/calendar/apps/web/src/lib/api/base-client.ts b/apps/calendar/apps/web/src/lib/api/base-client.ts deleted file mode 100644 index 0b53439e3..000000000 --- a/apps/calendar/apps/web/src/lib/api/base-client.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Base API Client Factory - * Eliminates duplication between calendar and todo API clients - */ - -import { browser } from '$app/environment'; - -export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - -export interface FetchOptions { - method?: HttpMethod; - body?: unknown; - token?: string; - isFormData?: boolean; - timeout?: number; -} - -export interface ApiResult { - data: T | null; - error: Error | null; -} - -export interface ApiClientConfig { - baseUrl: string; - apiPrefix?: string; - getAuthToken?: () => string | null; - defaultTimeout?: number; -} - -/** - * Creates a configured API client for a specific backend - */ -export function createApiClient(config: ApiClientConfig) { - const { baseUrl, apiPrefix = '/api/v1', defaultTimeout = 30000 } = config; - - async function fetchApi(endpoint: string, options: FetchOptions = {}): Promise> { - const { method = 'GET', body, token, isFormData = false, timeout = defaultTimeout } = options; - - // Get auth token - let authToken = token; - if (!authToken && browser) { - authToken = config.getAuthToken?.() ?? localStorage.getItem('@auth/appToken') ?? undefined; - } - - // Setup abort controller for timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const headers: Record = {}; - - // Don't set Content-Type for FormData - browser sets it automatically with boundary - if (!isFormData) { - headers['Content-Type'] = 'application/json'; - } - - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const url = `${baseUrl}${apiPrefix}${endpoint}`; - - const response = await fetch(url, { - method, - headers, - body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: new Error(errorData.message || `API error: ${response.status}`), - }; - } - - // Handle empty responses (204 No Content) - if (response.status === 204) { - return { data: null, error: null }; - } - - const data = await response.json(); - return { data, error: null }; - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === 'AbortError') { - return { - data: null, - error: new Error('Request timed out'), - }; - } - - return { - data: null, - error: error instanceof Error ? error : new Error('Unknown error'), - }; - } - } - - return { fetchApi }; -} - -/** - * Helper to build query strings from object - */ -export function buildQueryString(params: Record): string { - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - searchParams.append(key, String(value)); - } - }); - const queryString = searchParams.toString(); - return queryString ? `?${queryString}` : ''; -} diff --git a/apps/calendar/apps/web/src/lib/api/birthdays.ts b/apps/calendar/apps/web/src/lib/api/birthdays.ts index 10e4ed9b4..9734a0cd8 100644 --- a/apps/calendar/apps/web/src/lib/api/birthdays.ts +++ b/apps/calendar/apps/web/src/lib/api/birthdays.ts @@ -4,13 +4,17 @@ */ import { env } from '$env/dynamic/public'; -import { createApiClient } from './base-client'; +import { createApiClient } from '@manacore/shared-api-client'; +import { authStore } from '$lib/stores/auth.svelte'; const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015'; const contactsClient = createApiClient({ baseUrl: CONTACTS_API_BASE, apiPrefix: '/api/v1', + getAuthToken: () => authStore.getValidToken(), + timeout: 30000, + debug: import.meta.env.DEV, }); // ============================================ @@ -61,8 +65,6 @@ interface BirthdaysResponse { // API Functions // ============================================ -const fetchContactsApi = contactsClient.fetchApi; - /** * Fetch all contacts with birthdays from Contacts service */ @@ -70,10 +72,13 @@ export async function getBirthdays(): Promise<{ data: ContactBirthdaySummary[] | null; error: Error | null; }> { - const result = await fetchContactsApi('/contacts/birthdays'); + const result = await contactsClient.get('/contacts/birthdays'); + if (result.error) { + return { data: null, error: new Error(result.error.message) }; + } return { data: result.data?.contacts || null, - error: result.error, + error: null, }; } diff --git a/apps/calendar/apps/web/src/lib/api/client.ts b/apps/calendar/apps/web/src/lib/api/client.ts index 94103f722..5ca7b5a7b 100644 --- a/apps/calendar/apps/web/src/lib/api/client.ts +++ b/apps/calendar/apps/web/src/lib/api/client.ts @@ -1,26 +1,68 @@ /** * API Client for Calendar Backend + * Uses @manacore/shared-api-client for consistent error handling * * Token handling: Uses authStore.getValidToken() which automatically * refreshes expired tokens before making requests. */ import { env } from '$env/dynamic/public'; -import { createApiClient, type FetchOptions, type ApiResult } from './base-client'; +import { createApiClient, type ApiResult } from '@manacore/shared-api-client'; +import { authStore } from '$lib/stores/auth.svelte'; const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014'; -const calendarClient = createApiClient({ +/** + * Calendar API client instance + * - Auto token handling via authStore.getValidToken() + * - Consistent ApiResult response format + */ +const api = createApiClient({ baseUrl: API_BASE, apiPrefix: '/api/v1', + getAuthToken: () => authStore.getValidToken(), + timeout: 30000, + debug: import.meta.env.DEV, }); +/** + * Legacy fetchApi interface for backwards compatibility + */ +export interface FetchOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + token?: string; + isFormData?: boolean; + timeout?: number; +} + +/** + * Fetch API wrapper using shared client + * Maintains backward compatibility with existing code + */ export async function fetchApi( endpoint: string, options: FetchOptions = {} ): Promise> { - return calendarClient.fetchApi(endpoint, options); + const { method = 'GET', body, isFormData = false } = options; + + if (isFormData && body instanceof FormData) { + return api.upload(endpoint, body); + } + + switch (method) { + case 'POST': + return api.post(endpoint, body); + case 'PUT': + return api.put(endpoint, body); + case 'PATCH': + return api.patch(endpoint, body); + case 'DELETE': + return api.delete(endpoint); + default: + return api.get(endpoint); + } } // Re-export types for backwards compatibility -export type { FetchOptions, ApiResult }; +export type { ApiResult }; diff --git a/apps/calendar/apps/web/src/lib/api/todos.ts b/apps/calendar/apps/web/src/lib/api/todos.ts index db72aac5d..a7c3328b0 100644 --- a/apps/calendar/apps/web/src/lib/api/todos.ts +++ b/apps/calendar/apps/web/src/lib/api/todos.ts @@ -4,13 +4,17 @@ */ import { env } from '$env/dynamic/public'; -import { createApiClient, buildQueryString } from './base-client'; +import { createApiClient, buildQueryString, type ApiResult } from '@manacore/shared-api-client'; +import { authStore } from '$lib/stores/auth.svelte'; const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018'; const todoClient = createApiClient({ baseUrl: TODO_API_BASE, apiPrefix: '/api/v1', + getAuthToken: () => authStore.getValidToken(), + timeout: 30000, + debug: import.meta.env.DEV, }); // ============================================ @@ -173,7 +177,35 @@ interface LabelsResponse { // API Client (using shared base client) // ============================================ -const fetchTodoApi = todoClient.fetchApi; +async function fetchTodoApi( + endpoint: string, + options: { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown } = {} +): Promise<{ data: T | null; error: Error | null }> { + const { method = 'GET', body } = options; + + let result: ApiResult; + switch (method) { + case 'POST': + result = await todoClient.post(endpoint, body); + break; + case 'PUT': + result = await todoClient.put(endpoint, body); + break; + case 'PATCH': + result = await todoClient.patch(endpoint, body); + break; + case 'DELETE': + result = await todoClient.delete(endpoint); + break; + default: + result = await todoClient.get(endpoint); + } + + if (result.error) { + return { data: null, error: new Error(result.error.message) }; + } + return { data: result.data, error: null }; +} // ============================================ // Task API Functions @@ -182,7 +214,9 @@ const fetchTodoApi = todoClient.fetchApi; export async function getTasks( query: TaskQuery = {} ): Promise<{ data: Task[] | null; error: Error | null }> { - const queryString = buildQueryString(query as Record); + const queryString = buildQueryString( + query as Record + ); const result = await fetchTodoApi(`/tasks${queryString}`); return { data: result.data?.tasks || null, diff --git a/apps/nutriphi/apps/web/package.json b/apps/nutriphi/apps/web/package.json index a4a14d0e2..cdde6aa6a 100644 --- a/apps/nutriphi/apps/web/package.json +++ b/apps/nutriphi/apps/web/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@nutriphi/shared": "workspace:*", + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", diff --git a/apps/nutriphi/apps/web/src/lib/api/client.ts b/apps/nutriphi/apps/web/src/lib/api/client.ts index afaed66da..eb57a500a 100644 --- a/apps/nutriphi/apps/web/src/lib/api/client.ts +++ b/apps/nutriphi/apps/web/src/lib/api/client.ts @@ -1,68 +1,65 @@ +/** + * API Client for NutriPhi 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 { PUBLIC_BACKEND_URL } from '$env/static/public'; const BASE_URL = PUBLIC_BACKEND_URL || 'http://localhost:3023'; +/** + * NutriPhi API client instance + * - Auto token handling via authStore.getAccessToken() + * - Consistent ApiResult response format + */ +const api = createApiClient({ + baseUrl: BASE_URL, + apiPrefix: '/api/v1', + getAuthToken: () => authStore.getAccessToken(), + timeout: 30000, + debug: import.meta.env.DEV, +}); + +/** + * Legacy ApiClient class wrapper for backward compatibility + * Maintains throw-based error handling for existing code + */ class ApiClient { - private async getHeaders(): Promise { - const token = await authStore.getAccessToken(); - return { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; - } - async get(path: string): Promise { - const response = await fetch(`${BASE_URL}/api/v1${path}`, { - method: 'GET', - headers: await this.getHeaders(), - }); - - if (!response.ok) { - throw new Error(`API Error: ${response.status}`); + const result = await api.get(path); + if (result.error) { + throw new Error(result.error.message); } - - return response.json(); + return result.data as T; } async post(path: string, data: unknown): Promise { - const response = await fetch(`${BASE_URL}/api/v1${path}`, { - method: 'POST', - headers: await this.getHeaders(), - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error(`API Error: ${response.status}`); + const result = await api.post(path, data); + if (result.error) { + throw new Error(result.error.message); } - - return response.json(); + return result.data as T; } async patch(path: string, data: unknown): Promise { - const response = await fetch(`${BASE_URL}/api/v1${path}`, { - method: 'PATCH', - headers: await this.getHeaders(), - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error(`API Error: ${response.status}`); + const result = await api.patch(path, data); + if (result.error) { + throw new Error(result.error.message); } - - return response.json(); + return result.data as T; } async delete(path: string): Promise { - const response = await fetch(`${BASE_URL}/api/v1${path}`, { - method: 'DELETE', - headers: await this.getHeaders(), - }); - - if (!response.ok) { - throw new Error(`API Error: ${response.status}`); + const result = await api.delete(path); + if (result.error) { + throw new Error(result.error.message); } } } export const apiClient = new ApiClient(); + +// Re-export types for convenience +export type { ApiResult }; diff --git a/apps/picture/apps/web/package.json b/apps/picture/apps/web/package.json index 10492f1b5..e3b4b8224 100644 --- a/apps/picture/apps/web/package.json +++ b/apps/picture/apps/web/package.json @@ -16,6 +16,7 @@ "clean": "rm -rf .svelte-kit build node_modules" }, "dependencies": { + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", diff --git a/apps/picture/apps/web/src/lib/api/client.ts b/apps/picture/apps/web/src/lib/api/client.ts index 3cac5de48..4c7a650ac 100644 --- a/apps/picture/apps/web/src/lib/api/client.ts +++ b/apps/picture/apps/web/src/lib/api/client.ts @@ -1,18 +1,31 @@ /** * API Client for Picture Backend - * Replaces direct Supabase calls with backend API calls. + * Uses @manacore/shared-api-client for consistent error handling * * Token handling: * - Uses authStore.getValidToken() which automatically refreshes expired tokens - * - The fetch interceptor (setupFetchInterceptor) handles 401 responses by refreshing and retrying - * - If refresh fails, the request fails and user should be redirected to login + * - Consistent ApiResult response format */ import { env } from '$env/dynamic/public'; +import { createApiClient, type ApiResult } from '@manacore/shared-api-client'; import { authStore } from '$lib/stores/auth.svelte'; const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3006'; +/** + * Picture API client instance + * - Auto token handling via authStore.getValidToken() + * - Consistent ApiResult response format + */ +const api = createApiClient({ + baseUrl: API_BASE, + apiPrefix: '/api/v1', + getAuthToken: () => authStore.getValidToken(), + timeout: 30000, + debug: import.meta.env.DEV, +}); + type FetchOptions = { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; @@ -20,54 +33,44 @@ type FetchOptions = { isFormData?: boolean; }; +/** + * Legacy fetchApi wrapper for backward compatibility + * Returns { data, error } format where error is Error | null + */ export async function fetchApi( endpoint: string, options: FetchOptions = {} ): Promise<{ data: T | null; error: Error | null }> { - const { method = 'GET', body, token, isFormData = false } = options; + const { method = 'GET', body, isFormData = false } = options; - // Get a valid token (auto-refreshes if expired) - const authToken = token || (await authStore.getValidToken()); + let result: ApiResult; - try { - const headers: Record = {}; - - // Don't set Content-Type for FormData - browser sets it automatically with boundary - if (!isFormData) { - headers['Content-Type'] = 'application/json'; + if (isFormData && body instanceof FormData) { + result = await api.upload(endpoint, body); + } else { + switch (method) { + case 'POST': + result = await api.post(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); } + } - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const response = await fetch(`${API_BASE}/api/v1${endpoint}`, { - method, - headers, - body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: new Error(errorData.message || `API error: ${response.status}`), - }; - } - - // Handle empty responses (204 No Content) - if (response.status === 204) { - return { data: null, error: null }; - } - - const data = await response.json(); - return { data, error: null }; - } catch (error) { + // Convert ApiResult to legacy format + if (result.error) { return { data: null, - error: error instanceof Error ? error : new Error('Unknown error'), + error: new Error(result.error.message), }; } + return { data: result.data, error: null }; } /** @@ -78,40 +81,18 @@ export async function uploadFile( file: File, token?: string ): Promise<{ data: any; error: Error | null }> { - // Get a valid token (auto-refreshes if expired) - const authToken = token || (await authStore.getValidToken()); + const formData = new FormData(); + formData.append('file', file); - try { - const formData = new FormData(); - formData.append('file', file); + const result = await api.upload(endpoint, formData); - const headers: Record = {}; - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const response = await fetch(`${API_BASE}/api/v1${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) { + if (result.error) { return { data: null, - error: error instanceof Error ? error : new Error('Upload failed'), + error: new Error(result.error.message), }; } + return { data: result.data, error: null }; } /** @@ -122,40 +103,18 @@ export async function uploadFiles( files: File[], token?: string ): Promise<{ data: any; error: Error | null }> { - // Get a valid token (auto-refreshes if expired) - const authToken = token || (await authStore.getValidToken()); + const formData = new FormData(); + files.forEach((file) => { + formData.append('files', file); + }); - try { - const formData = new FormData(); - files.forEach((file) => { - formData.append('files', file); - }); + const result = await api.upload(endpoint, formData); - const headers: Record = {}; - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const response = await fetch(`${API_BASE}/api/v1${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) { + if (result.error) { return { data: null, - error: error instanceof Error ? error : new Error('Upload failed'), + error: new Error(result.error.message), }; } + return { data: result.data, error: null }; } diff --git a/apps/planta/apps/web/package.json b/apps/planta/apps/web/package.json index bc43d4c1a..b3edd3767 100644 --- a/apps/planta/apps/web/package.json +++ b/apps/planta/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/planta/apps/web/src/lib/api/client.ts b/apps/planta/apps/web/src/lib/api/client.ts index c542fb33b..b56295c55 100644 --- a/apps/planta/apps/web/src/lib/api/client.ts +++ b/apps/planta/apps/web/src/lib/api/client.ts @@ -1,19 +1,31 @@ /** * API Client for Planta backend + * Uses @manacore/shared-api-client for consistent error handling */ -import { browser } from '$app/environment'; +import { createApiClient, type ApiResult } from '@manacore/shared-api-client'; import { authStore } from '$lib/stores/auth.svelte'; -function getBackendUrl(): string { - if (browser && typeof window !== 'undefined') { - const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) - .__PUBLIC_BACKEND_URL__; - return injectedUrl || 'http://localhost:3022'; - } - return 'http://localhost:3022'; -} +const BASE_URL = 'http://localhost:3022'; +/** + * Planta API client instance + * - Auto token handling via authStore.getValidToken() + * - Runtime URL injection via window.__PUBLIC_BACKEND_URL__ + * - Consistent ApiResult response format + */ +const api = createApiClient({ + baseUrl: BASE_URL, + apiPrefix: '/api/v1', + getAuthToken: () => authStore.getValidToken(), + timeout: 30000, + debug: import.meta.env.DEV, +}); + +/** + * Legacy fetchApi wrapper for backward compatibility + * Returns { data, error } format + */ export async function fetchApi( endpoint: string, options: { @@ -27,36 +39,28 @@ export async function fetchApi( return { data: null, error: 'Not authenticated' }; } - const headers: Record = { - Authorization: `Bearer ${token}`, - }; + let result: ApiResult; - // Don't set Content-Type for FormData - browser will set it with boundary - if (!options.formData) { - headers['Content-Type'] = 'application/json'; - } - - try { - const response = await fetch(`${getBackendUrl()}/api/v1${endpoint}`, { - method: options.method || 'GET', - headers, - body: options.formData || (options.body ? JSON.stringify(options.body) : undefined), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: errorData.message || `API error: ${response.status}`, - }; + if (options.formData) { + result = await api.upload(endpoint, options.formData); + } else { + switch (options.method) { + case 'POST': + result = await api.post(endpoint, options.body); + break; + case 'PUT': + result = await api.put(endpoint, options.body); + break; + case 'DELETE': + result = await api.delete(endpoint); + break; + default: + result = await api.get(endpoint); } - - const data = await response.json(); - return { data, error: null }; - } catch (error) { - return { - data: null, - error: error instanceof Error ? error.message : 'Unknown error', - }; } + + if (result.error) { + return { data: null, error: result.error.message }; + } + return { data: result.data, error: null }; } diff --git a/apps/questions/apps/web/package.json b/apps/questions/apps/web/package.json index 4f25c7e0c..6f51ad0e9 100644 --- a/apps/questions/apps/web/package.json +++ b/apps/questions/apps/web/package.json @@ -29,6 +29,7 @@ "vite": "^6.0.0" }, "dependencies": { + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-types": "workspace:*", "@manacore/shared-utils": "workspace:*", diff --git a/apps/questions/apps/web/src/lib/api/client.ts b/apps/questions/apps/web/src/lib/api/client.ts index b874f2920..472e7234d 100644 --- a/apps/questions/apps/web/src/lib/api/client.ts +++ b/apps/questions/apps/web/src/lib/api/client.ts @@ -1,96 +1,86 @@ -import { browser } from '$app/environment'; +/** + * API Client for Questions 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 BASE_URL = PUBLIC_BACKEND_URL || 'http://localhost:3011'; -interface ApiError { - message: string; - statusCode: number; -} +// Token storage for manual token management +let currentToken: string | null = null; /** - * Get the backend URL, preferring runtime-injected value in browser + * Questions API client instance + * - Supports manual token setting via setAccessToken() + * - Runtime URL injection via window.__PUBLIC_BACKEND_URL__ + * - Consistent ApiResult response format */ -function getBackendUrl(): string { - if (browser && typeof window !== 'undefined') { - const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string }) - .__PUBLIC_BACKEND_URL__; - if (runtimeUrl) { - return runtimeUrl; - } - } - return PUBLIC_BACKEND_URL || 'http://localhost:3011'; -} +const api = createApiClient({ + baseUrl: BASE_URL, + apiPrefix: '', + getAuthToken: async () => currentToken, + timeout: 30000, + debug: import.meta.env.DEV, +}); +/** + * Legacy ApiClient class wrapper for backward compatibility + * Maintains throw-based error handling for existing code + */ class ApiClient { - private accessToken: string | null = null; - - private get baseUrl(): string { - return getBackendUrl(); - } - setAccessToken(token: string | null) { - this.accessToken = token; + currentToken = token; } getAccessToken(): string | null { - return this.accessToken; + return currentToken; } - async fetch(endpoint: string, options: ApiOptions = {}): Promise { - const { method = 'GET', body, headers = {} } = options; + async fetch(endpoint: string, options: { method?: string; body?: unknown } = {}): Promise { + const { method = 'GET', body } = options; - const requestHeaders: Record = { - 'Content-Type': 'application/json', - ...headers, - }; - - if (this.accessToken) { - requestHeaders['Authorization'] = `Bearer ${this.accessToken}`; + let result: ApiResult; + switch (method) { + case 'POST': + result = await api.post(endpoint, body); + break; + case 'PUT': + result = await api.put(endpoint, body); + break; + case 'DELETE': + result = await api.delete(endpoint); + break; + default: + result = await api.get(endpoint); } - 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); + if (result.error) { + throw new Error(result.error.message); } - if (response.status === 204) { + if (result.data === null) { return {} as T; } - return response.json() as Promise; + return result.data; } get(endpoint: string, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'GET', headers }); + return this.fetch(endpoint, { method: 'GET' }); } post(endpoint: string, body?: unknown, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'POST', body, headers }); + return this.fetch(endpoint, { method: 'POST', body }); } put(endpoint: string, body?: unknown, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'PUT', body, headers }); + return this.fetch(endpoint, { method: 'PUT', body }); } delete(endpoint: string, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'DELETE', headers }); + return this.fetch(endpoint, { method: 'DELETE' }); } } diff --git a/apps/skilltree/apps/web/package.json b/apps/skilltree/apps/web/package.json index 983577a65..dc15e0cc4 100644 --- a/apps/skilltree/apps/web/package.json +++ b/apps/skilltree/apps/web/package.json @@ -31,6 +31,7 @@ "vitest": "^4.0.18" }, "dependencies": { + "@manacore/shared-api-client": "workspace:*", "@manacore/shared-auth": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", diff --git a/apps/skilltree/apps/web/src/lib/api/client.ts b/apps/skilltree/apps/web/src/lib/api/client.ts index 29bcbb7f4..01e38492c 100644 --- a/apps/skilltree/apps/web/src/lib/api/client.ts +++ b/apps/skilltree/apps/web/src/lib/api/client.ts @@ -1,97 +1,86 @@ -import { browser } from '$app/environment'; +/** + * API Client for SkillTree 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 BASE_URL = PUBLIC_BACKEND_URL || 'http://localhost:3024'; -interface ApiError { - message: string; - statusCode: number; -} +// Token storage for manual token management +let currentToken: string | null = null; /** - * Get the backend URL, preferring runtime-injected value in browser - * This allows Docker to inject PUBLIC_BACKEND_URL_CLIENT at runtime + * SkillTree API client instance + * - Supports manual token setting via setAccessToken() + * - Runtime URL injection via window.__PUBLIC_BACKEND_URL__ + * - Consistent ApiResult response format */ -function getBackendUrl(): string { - if (browser && typeof window !== 'undefined') { - const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string }) - .__PUBLIC_BACKEND_URL__; - if (runtimeUrl) { - return runtimeUrl; - } - } - return PUBLIC_BACKEND_URL || 'http://localhost:3024'; -} +const api = createApiClient({ + baseUrl: BASE_URL, + apiPrefix: '', + getAuthToken: async () => currentToken, + timeout: 30000, + debug: import.meta.env.DEV, +}); +/** + * Legacy ApiClient class wrapper for backward compatibility + * Maintains throw-based error handling for existing code + */ class ApiClient { - private accessToken: string | null = null; - - private get baseUrl(): string { - return getBackendUrl(); - } - setAccessToken(token: string | null) { - this.accessToken = token; + currentToken = token; } getAccessToken(): string | null { - return this.accessToken; + return currentToken; } - async fetch(endpoint: string, options: ApiOptions = {}): Promise { - const { method = 'GET', body, headers = {} } = options; + async fetch(endpoint: string, options: { method?: string; body?: unknown } = {}): Promise { + const { method = 'GET', body } = options; - const requestHeaders: Record = { - 'Content-Type': 'application/json', - ...headers, - }; - - if (this.accessToken) { - requestHeaders['Authorization'] = `Bearer ${this.accessToken}`; + let result: ApiResult; + switch (method) { + case 'POST': + result = await api.post(endpoint, body); + break; + case 'PUT': + result = await api.put(endpoint, body); + break; + case 'DELETE': + result = await api.delete(endpoint); + break; + default: + result = await api.get(endpoint); } - 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); + if (result.error) { + throw new Error(result.error.message); } - if (response.status === 204) { + if (result.data === null) { return {} as T; } - return response.json() as Promise; + return result.data; } get(endpoint: string, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'GET', headers }); + return this.fetch(endpoint, { method: 'GET' }); } post(endpoint: string, body?: unknown, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'POST', body, headers }); + return this.fetch(endpoint, { method: 'POST', body }); } put(endpoint: string, body?: unknown, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'PUT', body, headers }); + return this.fetch(endpoint, { method: 'PUT', body }); } delete(endpoint: string, headers?: Record): Promise { - return this.fetch(endpoint, { method: 'DELETE', headers }); + return this.fetch(endpoint, { method: 'DELETE' }); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 971e84d25..5f1a1ed80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,9 @@ importers: '@calendar/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 @@ -2286,6 +2289,9 @@ importers: apps/nutriphi/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 @@ -2765,6 +2771,9 @@ importers: apps/picture/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 @@ -3054,6 +3063,9 @@ importers: apps/planta/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 @@ -3530,6 +3542,9 @@ importers: apps/questions/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 @@ -3694,6 +3709,9 @@ importers: apps/skilltree/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