♻️ refactor: migrate calendar, picture, nutriphi, planta, questions, skilltree to shared-api-client

- 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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 14:32:47 +01:00
parent 75b5fb2fae
commit 1e5175e522
16 changed files with 354 additions and 429 deletions

View file

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

View file

@ -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<T> {
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<T>(endpoint: string, options: FetchOptions = {}): Promise<ApiResult<T>> {
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<string, string> = {};
// 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, unknown>): 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}` : '';
}

View file

@ -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<BirthdaysResponse>('/contacts/birthdays');
const result = await contactsClient.get<BirthdaysResponse>('/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,
};
}

View file

@ -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<T> 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<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<ApiResult<T>> {
return calendarClient.fetchApi<T>(endpoint, options);
const { method = 'GET', body, isFormData = false } = options;
if (isFormData && body instanceof FormData) {
return api.upload<T>(endpoint, body);
}
switch (method) {
case 'POST':
return api.post<T>(endpoint, body);
case 'PUT':
return api.put<T>(endpoint, body);
case 'PATCH':
return api.patch<T>(endpoint, body);
case 'DELETE':
return api.delete<T>(endpoint);
default:
return api.get<T>(endpoint);
}
}
// Re-export types for backwards compatibility
export type { FetchOptions, ApiResult };
export type { ApiResult };

View file

@ -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<T>(
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<T>;
switch (method) {
case 'POST':
result = await todoClient.post<T>(endpoint, body);
break;
case 'PUT':
result = await todoClient.put<T>(endpoint, body);
break;
case 'PATCH':
result = await todoClient.patch<T>(endpoint, body);
break;
case 'DELETE':
result = await todoClient.delete<T>(endpoint);
break;
default:
result = await todoClient.get<T>(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<string, unknown>);
const queryString = buildQueryString(
query as Record<string, string | number | boolean | undefined>
);
const result = await fetchTodoApi<TasksResponse>(`/tasks${queryString}`);
return {
data: result.data?.tasks || null,

View file

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

View file

@ -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<T> 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<HeadersInit> {
const token = await authStore.getAccessToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
async get<T>(path: string): Promise<T> {
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<T>(path);
if (result.error) {
throw new Error(result.error.message);
}
return response.json();
return result.data as T;
}
async post<T>(path: string, data: unknown): Promise<T> {
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<T>(path, data);
if (result.error) {
throw new Error(result.error.message);
}
return response.json();
return result.data as T;
}
async patch<T>(path: string, data: unknown): Promise<T> {
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<T>(path, data);
if (result.error) {
throw new Error(result.error.message);
}
return response.json();
return result.data as T;
}
async delete(path: string): Promise<void> {
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<void>(path);
if (result.error) {
throw new Error(result.error.message);
}
}
}
export const apiClient = new ApiClient();
// Re-export types for convenience
export type { ApiResult };

View file

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

View file

@ -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<T> 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<T> 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<T>(
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<T>;
try {
const headers: Record<string, string> = {};
// 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<T>(endpoint, body);
} else {
switch (method) {
case 'POST':
result = await api.post<T>(endpoint, body);
break;
case 'PATCH':
result = await api.patch<T>(endpoint, body);
break;
case 'DELETE':
result = await api.delete<T>(endpoint);
break;
default:
result = await api.get<T>(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<any>(endpoint, formData);
const headers: Record<string, string> = {};
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<any>(endpoint, formData);
const headers: Record<string, string> = {};
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 };
}

View file

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

View file

@ -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<T> 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<T>(
endpoint: string,
options: {
@ -27,36 +39,28 @@ export async function fetchApi<T>(
return { data: null, error: 'Not authenticated' };
}
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
let result: ApiResult<T>;
// 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<T>(endpoint, options.formData);
} else {
switch (options.method) {
case 'POST':
result = await api.post<T>(endpoint, options.body);
break;
case 'PUT':
result = await api.put<T>(endpoint, options.body);
break;
case 'DELETE':
result = await api.delete<T>(endpoint);
break;
default:
result = await api.get<T>(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 };
}

View file

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

View file

@ -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<string, string>;
}
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<T> 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<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
async fetch<T>(endpoint: string, options: { method?: string; body?: unknown } = {}): Promise<T> {
const { method = 'GET', body } = options;
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
if (this.accessToken) {
requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
let result: ApiResult<T>;
switch (method) {
case 'POST':
result = await api.post<T>(endpoint, body);
break;
case 'PUT':
result = await api.put<T>(endpoint, body);
break;
case 'DELETE':
result = await api.delete<T>(endpoint);
break;
default:
result = await api.get<T>(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<T>;
return result.data;
}
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET', headers });
return this.fetch<T>(endpoint, { method: 'GET' });
}
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
return this.fetch<T>(endpoint, { method: 'POST', body });
}
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
return this.fetch<T>(endpoint, { method: 'PUT', body });
}
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
return this.fetch<T>(endpoint, { method: 'DELETE' });
}
}

View file

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

View file

@ -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<string, string>;
}
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<T> 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<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
async fetch<T>(endpoint: string, options: { method?: string; body?: unknown } = {}): Promise<T> {
const { method = 'GET', body } = options;
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
if (this.accessToken) {
requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
let result: ApiResult<T>;
switch (method) {
case 'POST':
result = await api.post<T>(endpoint, body);
break;
case 'PUT':
result = await api.put<T>(endpoint, body);
break;
case 'DELETE':
result = await api.delete<T>(endpoint);
break;
default:
result = await api.get<T>(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<T>;
return result.data;
}
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET', headers });
return this.fetch<T>(endpoint, { method: 'GET' });
}
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
return this.fetch<T>(endpoint, { method: 'POST', body });
}
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
return this.fetch<T>(endpoint, { method: 'PUT', body });
}
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
return this.fetch<T>(endpoint, { method: 'DELETE' });
}
}

18
pnpm-lock.yaml generated
View file

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