/** * services/api.ts — Axios-based API client for mobile. * * Features: * - Base URL from EXPO_PUBLIC_API_URL (with platform-aware fallback). * - Bearer token interceptor (expo-secure-store). * - 401 single-flight refresh-token retry. * - Multipart upload helper for inspection images. * - Endpoints exposed as `api.auth.*`, `api.inspections.*`. */ import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig, } from 'axios'; import { Platform } from 'react-native'; import type { HealthResponse, InspectionCreateResponse, InspectionStatusResponse, SyncInspectionResponse, InspectionListResponse, } from '@arac-hasar/types'; import { clearTokens, getAccessToken, getRefreshToken, setTokens, } from './auth'; // ---- Base URL resolution ---------------------------------------------------- function resolveBaseUrl(): string { const fromEnv = process.env.EXPO_PUBLIC_API_URL; if (fromEnv && fromEnv.trim().length > 0) { return fromEnv.replace(/\/+$/, ''); } // Sensible per-platform defaults for local development. if (Platform.OS === 'android') return 'http://10.0.2.2:8000'; return 'http://localhost:8000'; } export const API_BASE = resolveBaseUrl(); const DEFAULT_API_KEY = process.env.EXPO_PUBLIC_API_KEY || ''; // ---- Axios instance --------------------------------------------------------- export const http: AxiosInstance = axios.create({ baseURL: API_BASE, timeout: 30_000, headers: { Accept: 'application/json' }, }); // Attach bearer token + optional API key http.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { const token = await getAccessToken(); config.headers = config.headers ?? {}; if (token) { (config.headers as Record).Authorization = `Bearer ${token}`; } if (DEFAULT_API_KEY) { (config.headers as Record)['X-API-Key'] = DEFAULT_API_KEY; } return config; }); // Single-flight refresh let refreshPromise: Promise | null = null; async function refreshAccessToken(): Promise { if (refreshPromise) return refreshPromise; refreshPromise = (async () => { const refresh = await getRefreshToken(); if (!refresh) return null; try { const { data } = await axios.post<{ access_token: string; refresh_token?: string }>( `${API_BASE}/auth/refresh`, { refresh_token: refresh }, { timeout: 15_000 }, ); if (data?.access_token) { await setTokens({ access_token: data.access_token, refresh_token: data.refresh_token ?? refresh, }); return data.access_token; } } catch { /* fall through */ } return null; })().finally(() => { refreshPromise = null; }); return refreshPromise; } http.interceptors.response.use( (r) => r, async (error: AxiosError) => { const original = error.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined; if ( error.response?.status === 401 && original && !original._retry && !original.url?.includes('/auth/login') && !original.url?.includes('/auth/register') && !original.url?.includes('/auth/refresh') ) { original._retry = true; const newToken = await refreshAccessToken(); if (newToken) { original.headers = original.headers ?? {}; (original.headers as Record).Authorization = `Bearer ${newToken}`; return http.request(original); } // Could not refresh — clear tokens so RootNavigator routes to login. await clearTokens(); } return Promise.reject(error); }, ); // ---- Helpers ---------------------------------------------------------------- function appendFile(form: FormData, uri: string, name: string, field = 'files'): void { // React Native FormData file object — must include uri/name/type. // On Android iOS the picker/camera uri is already `file://...` or `content://...`; // do not strip the scheme. iOS Simulator returns `file://` paths. const lower = uri.toLowerCase(); const ext = lower.endsWith('.png') ? 'png' : lower.endsWith('.heic') ? 'heic' : 'jpg'; const mime = ext === 'png' ? 'image/png' : ext === 'heic' ? 'image/heic' : 'image/jpeg'; const finalName = name.includes('.') ? name : `${name}.${ext}`; // RN's FormData accepts {uri,name,type}; the typings don't model this so we cast. form.append(field, { uri, name: finalName, type: mime } as unknown as Blob); } export interface LoginPayload { email: string; password: string; } export interface RegisterPayload { email: string; password: string; full_name?: string; company?: string; } export interface AuthResponse { access_token: string; refresh_token?: string; user?: { id?: string | number; email: string; full_name?: string; company?: string; }; } // ---- API surface ------------------------------------------------------------ interface InspectionApi { createAsync: (imageUris: string[]) => Promise; createSync: (imageUri: string) => Promise; get: (id: string) => Promise; list: (opts?: { page?: number; pageSize?: number }) => Promise; visualizationUrl: (id: string, type: 'annotated' | 'parts' | 'damages') => string; } interface AuthApi { login: (p: LoginPayload) => Promise; register: (p: RegisterPayload) => Promise; me: () => Promise; changePassword: (current: string, next: string) => Promise; logout: () => Promise; } export interface ApiSurface { health: () => Promise; auth: AuthApi; inspections: InspectionApi; // Legacy helpers kept for older screens. createInspection: (imageUris: string[]) => Promise; getInspection: (id: string) => Promise; listInspections: (opts?: { page?: number; pageSize?: number }) => Promise; syncInspect: (uri: string) => Promise; visualizationUrl: (id: string, type: 'annotated' | 'parts' | 'damages') => string; } export const api: ApiSurface = { // Health async health(): Promise { const { data } = await http.get('/health'); return data; }, auth: { async login(payload: LoginPayload): Promise { const { data } = await http.post('/auth/login', payload); if (data?.access_token) { await setTokens({ access_token: data.access_token, refresh_token: data.refresh_token, }); } return data; }, async register(payload: RegisterPayload): Promise { const { data } = await http.post('/auth/register', payload); if (data?.access_token) { await setTokens({ access_token: data.access_token, refresh_token: data.refresh_token, }); } return data; }, async me(): Promise { const { data } = await http.get('/auth/me'); return data; }, async changePassword(current_password: string, new_password: string): Promise { await http.post('/auth/change-password', { current_password, new_password, }); }, async logout(): Promise { try { await http.post('/auth/logout'); } catch { /* ignore */ } await clearTokens(); }, }, inspections: { async createAsync(imageUris: string[]): Promise { const form = new FormData(); imageUris.forEach((uri, i) => appendFile(form, uri, `img_${i}.jpg`)); // NOTE: do NOT set Content-Type manually here — axios/RN FormData must // append the multipart boundary string itself, otherwise FastAPI/Starlette // will reject the body with a 400/422. const { data } = await http.post( '/api/v1/inspect?mode=async', form, { timeout: 60_000, transformRequest: (d) => d, // prevent axios from JSON.stringifying FormData }, ); return data; }, async createSync(imageUri: string): Promise { const form = new FormData(); appendFile(form, imageUri, 'img.jpg'); const { data } = await http.post( '/api/v1/inspect?mode=sync', form, { timeout: 90_000, transformRequest: (d) => d, }, ); return data; }, async get(inspectionId: string): Promise { const { data } = await http.get( `/api/v1/inspect/${encodeURIComponent(inspectionId)}`, ); return data; }, async list(opts: { page?: number; pageSize?: number } = {}): Promise { const { page = 1, pageSize = 20 } = opts; const { data } = await http.get( `/api/v1/inspect?page=${page}&page_size=${pageSize}`, ); return data; }, visualizationUrl( inspectionId: string, type: 'annotated' | 'parts' | 'damages', ): string { return `${API_BASE}/api/v1/inspect/${encodeURIComponent(inspectionId)}/visualization/${type}`; }, }, // Legacy helpers — required by older screens (CaptureFlow / Results / History.tsx). async createInspection(imageUris: string[]): Promise { const res = await api.inspections.createAsync(imageUris); return res.inspection_id; }, async getInspection(id: string): Promise { return api.inspections.get(id); }, async listInspections(opts?: { page?: number; pageSize?: number }): Promise { return api.inspections.list(opts); }, async syncInspect(uri: string): Promise { return api.inspections.createSync(uri); }, visualizationUrl(id: string, type: 'annotated' | 'parts' | 'damages'): string { return api.inspections.visualizationUrl(id, type); }, }; // ---- Error helper ----------------------------------------------------------- export function describeError(err: unknown, fallback = 'unknownError'): string { if (axios.isAxiosError(err)) { if (err.response?.data) { const data = err.response.data as { detail?: string; message?: string }; if (data.detail) return data.detail; if (data.message) return data.message; } if (err.code === 'ECONNABORTED' || err.message?.includes('Network')) { return 'networkError'; } return err.message || fallback; } if (err instanceof Error) return err.message; return fallback; } // ---- Legacy helpers preserved for existing screens -------------------------- export async function createInspection(imageUris: string[]): Promise { const res = await api.inspections.createAsync(imageUris); return res.inspection_id; } export async function getInspection( inspectionId: string, ): Promise { return api.inspections.get(inspectionId); } export default api;