Files
Kazier/mobile/api/client.ts
T
m 197c7db8a9 feat(mobile): boucle sync client↔serveur — outbox push + snapshot permissions
- api/types.ts : SyncOperation, SyncResult (sémantique `applied` = INDEX documentée),
  ResourcePermission (miroir docs/api-v1.md §6, consommé tel quel par saveResourcePermission)
- api/client.ts : syncOps (POST /sync/ops), getSyncPermissions (GET /sync/permissions?after=),
  hasAuthToken() ; INVALID_RESPONSE documenté dans docs/api-v1.md §1/§4
- services/db/repositories/pendingOps.ts : listQueuedOperations(limit) (batch FIFO due)
  + scheduleRetries(ids, retryAt) qui ne modifie JAMAIS attempts (erreur transitoire,
  contrairement à markPendingOperation('failed') qui incrémente + backoff)
- features/syncOutbox.ts (nouveau) :
  * pushPendingOps : batch ≤ SYNC_BATCH_SIZE (50) → ops [0, applied) passent 'completed' ;
    op à l'index applied refusée → 'failed' (backoff, dead-letter MAX_PENDING_ATTEMPTS) ;
    ops suivantes intactes (re-soumission) ; réseau/5xx → scheduleRetries (retried) sans
    dead-letter prématurée ; applied==0 && failed → rien commité, aucun compteur touché
  * refreshPermissions : delta monotone en mémoire lastPermissionCachedAt → `after`,
    upsert saveResourcePermission ; no-op sans token
- features/syncDevice.ts : le tick useSyncDevice() enchaîne SAF walk → pushPendingOps()
  → refreshPermissions(), tous deux gardés par hasAuthToken()
- tests/syncOutbox.test.ts : 12 cas (FIFO/limit, succès complet/partiel, applied=0,
  transitoire réseau + 5xx sans incrément, dead-letter après MAX_PENDING_ATTEMPTS,
  delta snapshot croisé) — npm run test:sync
- tests/e2e.live.test.ts : smoke real backend (register → push → relecture /files/folders →
  snapshot), skip si serveur down, SORTI de npm test via npm run test:e2e
- gates : npx tsc --noEmit + npm run test → 56/56 verts (24 db + 20 api + 12 sync)
2026-09-10 20:23:55 +02:00

191 lines
5.2 KiB
TypeScript

import type {
ApiData,
ApiErrorBody,
DeviceRegistration,
FileDto,
FolderDto,
ListFilesParams,
OcrJob,
ResourcePermission,
SyncOperation,
SyncResult,
} from './types';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
export const DEFAULT_TIMEOUT_MS = 15_000;
let authToken: string | null = null;
export function setAuthToken(token: string | null): void {
authToken = token;
}
export function hasAuthToken(): boolean {
return authToken !== null;
}
type QueryParams = Record<string, string | number | boolean | undefined | null>;
function toQuery(params?: QueryParams): string {
if ( !params ) return '';
const search = new URLSearchParams();
for ( const [key, value] of Object.entries(params) ) {
if ( value === undefined || value === null ) continue;
search.set(key, String(value));
}
const query = search.toString();
return query ? `?${query}` : '';
}
function mergeHeaders(init?: HeadersInit): HeadersInit | undefined {
if ( !authToken ) return init;
const merged = new Headers(init);
if ( !merged.has('Authorization') ) {
merged.set('Authorization', `Bearer ${authToken}`);
}
return merged;
}
export class ApiError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = 'ApiError';
this.code = code;
}
}
async function request<T>(
path: string,
init: RequestInit = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS
): Promise<ApiData<T>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: mergeHeaders(init.headers),
signal: controller.signal,
});
} catch {
throw new ApiError('NETWORK_ERROR', 'Serveur injoignable');
}
const body = (await response.json().catch(() => null)) as
| ApiData<T>
| ApiErrorBody
| unknown[]
| string
| number
| null;
if ( !response.ok ) {
const error = body != null && isErrorBody(body) ? body.error : null;
throw new ApiError(
error?.code ?? `HTTP_${response.status}`,
error?.message ?? response.statusText
);
}
// 2xx : enveloppe `{ data }` obligatoire. Un corps illisible (HTML d'un
// proxy, JSON malformé, corps vide) ou un objet sans clé `data` est une
// réponse invalide → ApiError propre, jamais de TypeError ni de data
// indéfini silencieux. Les listes brutes (`[]`) restent tolérées.
if ( body === null || isErrorBody(body) ) {
throw new ApiError('INVALID_RESPONSE', 'Réponse serveur invalide (enveloppe {data} attendue)');
}
if ( !isRecord(body) ) {
return { data: body as T };
}
if ( !('data' in body) ) {
throw new ApiError('INVALID_RESPONSE', 'Réponse serveur invalide (enveloppe {data} attendue)');
}
return body as ApiData<T>;
} finally {
clearTimeout(timer);
}
}
function isErrorBody(body: unknown): body is ApiErrorBody {
return (
typeof body === 'object' &&
body !== null &&
'error' in body &&
(body as { error?: { code?: unknown } }).error != null
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export const api = {
getApiBaseUrl: () => API_BASE_URL,
health: () => request<{ status: string }>('/health'),
registerDevice: (deviceId: string) =>
request<DeviceRegistration>('/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
}),
listFiles: (params?: ListFilesParams) =>
request<FileDto[]>(`/files${toQuery(params)}`),
getFile: (id: string) =>
request<FileDto>(`/files/${encodeURIComponent(id)}`),
deleteFile: (id: string) =>
request<{ id: string }>(`/files/${encodeURIComponent(id)}`, {
method: 'DELETE',
}),
searchFiles: (q: string, params?: QueryParams) =>
request<FileDto[]>(`/files/search${toQuery({ q, ...params })}`),
listFolders: () => request<FolderDto[]>('/files/folders'),
uploadFile: (
file: { uri: string; name: string; mimeType: string },
folderId?: string | null
) => {
const form = new FormData();
form.append('file', {
uri: file.uri,
name: file.name,
type: file.mimeType,
} as unknown as Blob);
if ( folderId ) form.append('folderId', folderId);
return request<FileDto>('/files/upload', {
method: 'POST',
body: form,
});
},
createOcrJob: (fileId: string) =>
request<OcrJob>('/ocr/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId }),
}),
getOcrJob: (id: string) =>
request<OcrJob>(`/ocr/jobs/${encodeURIComponent(id)}`),
syncOps: (operations: SyncOperation[]) =>
request<SyncResult>('/sync/ops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations }),
}),
getSyncPermissions: (after?: number) =>
request<ResourcePermission[]>(`/sync/permissions${after != null ? `?after=${after}` : ''}`),
};