♻️ refactor: migrate todo, contacts, storage to shared-api-client

- Update todo, contacts, storage web apps to use @manacore/shared-api-client
- Maintain backward compatibility with existing legacy wrappers
- Todo: apiClient wrapper for setAccessToken/getAccessToken pattern
- Contacts: fetchWithAuth/fetchWithAuthFormData wrappers
- Storage: toLegacyResponse wrapper for ApiResponse format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 14:27:11 +01:00
parent fa78769e82
commit 5322709fca
7 changed files with 191 additions and 185 deletions

View file

@ -30,6 +30,7 @@
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",

View file

@ -1,71 +1,80 @@
/**
* Centralized API client with authentication
* API Client for Contacts backend
* Uses @manacore/shared-api-client for consistent error handling
*/
import { createApiClient, type ApiResult } from '@manacore/shared-api-client';
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
const API_URL = PUBLIC_BACKEND_URL || 'http://localhost:3015';
/**
* Make an authenticated API request
* @param url API endpoint (will be prefixed with API_BASE)
* @param options Fetch options
* @returns Parsed JSON response
* Contacts API client instance
* - Auto token handling via authStore.getValidToken()
* - Consistent ApiResult<T> response format
*/
export const api = createApiClient({
baseUrl: API_URL,
apiPrefix: '/api/v1',
getAuthToken: () => authStore.getValidToken(),
timeout: 30000,
debug: import.meta.env.DEV,
});
/**
* Legacy fetchWithAuth wrapper for backward compatibility
* Converts ApiResult to throw-based pattern
*/
export async function fetchWithAuth<T = unknown>(
url: string,
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const method = options.method || 'GET';
const body = options.body ? JSON.parse(options.body as string) : undefined;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
let result: ApiResult<T>;
switch (method) {
case 'POST':
result = await api.post<T>(url, body);
break;
case 'PUT':
result = await api.put<T>(url, body);
break;
case 'PATCH':
result = await api.patch<T>(url, body);
break;
case 'DELETE':
result = await api.delete<T>(url);
break;
default:
result = await api.get<T>(url);
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
if (result.error) {
throw new Error(result.error.message);
}
return response.json();
return result.data as T;
}
/**
* Make an authenticated API request without JSON content type
* Used for file uploads (FormData)
* Legacy fetchWithAuthFormData for file uploads
* Uses the shared API client's upload method
*/
export async function fetchWithAuthFormData<T = unknown>(
url: string,
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const formData = options.body as FormData;
const result = await api.upload<T>(url, formData);
const headers: HeadersInit = {
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
if (result.error) {
throw new Error(result.error.message);
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
return result.data as T;
}
// Re-export types for convenience
export type { ApiResult };

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,48 +1,68 @@
/**
* API Client for Storage Backend
* Uses @manacore/shared-api-client for consistent error handling
*/
import { createApiClient, type ApiResult } from '@manacore/shared-api-client';
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE_URL = 'http://localhost:3016/api/v1';
const API_URL = 'http://localhost:3016';
/**
* Storage API client instance
* - Auto token handling via authStore.getValidToken()
* - Consistent ApiResult<T> response format
*/
const api = createApiClient({
baseUrl: API_URL,
apiPrefix: '/api/v1',
getAuthToken: () => authStore.getAccessToken(),
timeout: 30000,
debug: import.meta.env.DEV,
});
// Legacy type alias for backward compatibility
export interface ApiResponse<T> {
data?: T;
error?: string;
}
async function getHeaders(): Promise<HeadersInit> {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
/**
* Convert ApiResult to legacy ApiResponse format
*/
function toLegacyResponse<T>(result: ApiResult<T>): ApiResponse<T> {
if (result.error) {
return { error: result.error.message };
}
return headers;
return { data: result.data ?? undefined };
}
/**
* Legacy request wrapper for backward compatibility
*/
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
try {
const headers = await getHeaders();
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
...headers,
...(options.headers || {}),
},
});
const method = options.method || 'GET';
const body = options.body ? JSON.parse(options.body as string) : undefined;
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { error: errorData.message || `HTTP ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
let result: ApiResult<T>;
switch (method) {
case 'POST':
result = await api.post<T>(endpoint, body);
break;
case 'PUT':
result = await api.put<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);
}
return toLegacyResponse(result);
}
// File Types
@ -112,38 +132,20 @@ export const filesApi = {
get: (id: string) => request<StorageFile>(`/files/${id}`),
upload: async (file: File, folderId?: string): Promise<ApiResponse<StorageFile>> => {
const token = await authStore.getAccessToken();
const formData = new FormData();
formData.append('file', file);
if (folderId) {
formData.append('parentFolderId', folderId);
}
try {
const response = await fetch(`${API_BASE_URL}/files/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { error: errorData.message || `HTTP ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
const result = await api.upload<StorageFile>('/files/upload', formData);
return toLegacyResponse(result);
},
download: async (id: string): Promise<Blob | null> => {
const token = await authStore.getAccessToken();
try {
const response = await fetch(`${API_BASE_URL}/files/${id}/download`, {
const response = await fetch(`${API_URL}/api/v1/files/${id}/download`, {
headers: {
Authorization: `Bearer ${token}`,
},

View file

@ -30,6 +30,7 @@
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-splitscreen": "workspace:*",
"@manacore/shared-types": "workspace:*",

View file

@ -1,106 +1,89 @@
import { browser } from '$app/environment';
/**
* API Client for Todo backend
* Uses @manacore/shared-api-client for consistent error handling
*/
import { createApiClient, type ApiResult } from '@manacore/shared-api-client';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
interface ApiOptions {
method?: string;
body?: unknown;
headers?: Record<string, string>;
const API_URL = PUBLIC_BACKEND_URL || 'http://localhost:3018';
// Token storage for manual token management (legacy pattern)
let currentToken: string | null = null;
/**
* Todo API client instance
* - Supports manual token setting via setAccessToken()
* - Consistent ApiResult<T> response format
* - Runtime URL injection for Docker
*/
export const api = createApiClient({
baseUrl: API_URL,
apiPrefix: '',
getAuthToken: async () => currentToken,
timeout: 30000,
debug: import.meta.env.DEV,
});
/**
* Legacy token management functions
* Used by auth store to set token after login
*/
export function setAccessToken(token: string | null) {
currentToken = token;
}
interface ApiError {
message: string;
statusCode: number;
export function getAccessToken(): string | null {
return currentToken;
}
/**
* Get the backend URL, preferring runtime-injected value in browser
* This allows Docker to inject PUBLIC_BACKEND_URL_CLIENT at runtime
* instead of using the build-time PUBLIC_BACKEND_URL
* Wrapper for legacy code that expects throws instead of ApiResult
* Converts ApiResult to throw-based pattern for backward compatibility
*/
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
if (runtimeUrl) {
return runtimeUrl;
}
export async function fetchApi<T>(
endpoint: string,
options: { method?: string; body?: unknown } = {}
): Promise<T> {
const { method = 'GET', body } = options;
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 '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);
}
return PUBLIC_BACKEND_URL || 'http://localhost:3018';
if (result.error) {
throw new Error(result.error.message);
}
return result.data as T;
}
class ApiClient {
private accessToken: string | null = null;
/**
* Legacy apiClient wrapper for backward compatibility
*/
export const apiClient = {
setAccessToken,
getAccessToken,
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, body?: unknown) => fetchApi<T>(endpoint, { method: 'POST', body }),
put: <T>(endpoint: string, body?: unknown) => fetchApi<T>(endpoint, { method: 'PUT', body }),
patch: <T>(endpoint: string, body?: unknown) => fetchApi<T>(endpoint, { method: 'PATCH', body }),
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
};
// Use getter to evaluate URL at request time (browser may hydrate after construction)
private get baseUrl(): string {
return getBackendUrl();
}
setAccessToken(token: string | null) {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
async fetch<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
if (this.accessToken) {
requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method,
headers: requestHeaders,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorMessage = 'An error occurred';
try {
const errorData = (await response.json()) as ApiError;
errorMessage = errorData.message || errorMessage;
} catch {
errorMessage = response.statusText || errorMessage;
}
throw new Error(errorMessage);
}
// Handle 204 No Content
if (response.status === 204) {
return {} as T;
}
return response.json() as Promise<T>;
}
// Convenience methods
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET', headers });
}
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
}
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
}
patch<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PATCH', body, headers });
}
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
}
}
export const apiClient = new ApiClient();
// Re-export types for convenience
export type { ApiResult };

9
pnpm-lock.yaml generated
View file

@ -1085,6 +1085,9 @@ importers:
apps/contacts/apps/web:
dependencies:
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
@ -3866,6 +3869,9 @@ importers:
apps/storage/apps/web:
dependencies:
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
@ -4094,6 +4100,9 @@ importers:
apps/todo/apps/web:
dependencies:
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth