mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
♻️ 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:
parent
75b5fb2fae
commit
1e5175e522
16 changed files with 354 additions and 429 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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}` : '';
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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
18
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue