feat: add @manacore/shared-api-client package

Create unified API client for all web apps with:
- createApiClient factory function
- ApiResult<T> Go-style error handling
- HTTP methods: get, post, put, patch, delete, upload
- Auto token handling via getAuthToken callback
- Timeout support with AbortController
- Retry logic with exponential backoff
- Runtime URL injection for Docker
- FormData support for file uploads

Migrate clock app as proof of concept:
- Replace local fetchApi with shared createApiClient
- Update stores to use ApiError.message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 14:19:55 +01:00
parent 2b3210df85
commit e23d1194d8
12 changed files with 562 additions and 292 deletions

View file

@ -0,0 +1,94 @@
/**
* API Client Utilities
*/
import type { ApiError, ApiErrorCode } from './types';
/**
* Build a query string from parameters object
* Handles undefined values and proper encoding
*/
export function buildQueryString(
params: Record<string, string | number | boolean | undefined>
): string {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
/**
* Determine error code from HTTP status
*/
export function getErrorCodeFromStatus(status: number): ApiErrorCode {
if (status === 401) return 'UNAUTHORIZED';
if (status === 403) return 'FORBIDDEN';
if (status === 404) return 'NOT_FOUND';
if (status === 422 || status === 400) return 'VALIDATION_ERROR';
if (status >= 500) return 'SERVER_ERROR';
return 'UNKNOWN';
}
/**
* Create a standardized API error
*/
export function createApiError(
message: string,
code: ApiErrorCode,
status?: number,
details?: unknown
): ApiError {
return { message, code, status, details };
}
/**
* Parse error response body
*/
export async function parseErrorResponse(response: Response): Promise<string> {
try {
const data = await response.json();
return data.message || data.error || JSON.stringify(data);
} catch {
return response.statusText || 'Unknown error';
}
}
/**
* Sleep utility for retry delays
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Check if error is retryable (network issues, 5xx errors)
*/
export function isRetryableError(error: ApiError): boolean {
if (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') {
return true;
}
if (error.status && error.status >= 500) {
return true;
}
return false;
}
/**
* Get base URL with runtime injection support for Docker
* Checks window.__PUBLIC_BACKEND_URL__ first, then falls back to provided URL
*/
export function getBaseUrl(configuredUrl: string): string {
if (typeof window !== 'undefined') {
const runtimeUrl = (window as unknown as Record<string, unknown>).__PUBLIC_BACKEND_URL__;
if (typeof runtimeUrl === 'string' && runtimeUrl) {
return runtimeUrl;
}
}
return configuredUrl;
}