Hablar con la api: apiFetch
Todas las llamadas salen por una función, apiFetch() en services/api-client.ts. Pone las
cabeceras, renueva el token cuando toca y normaliza los errores. Ningún componente llama a fetch
por su cuenta.
Los tokens no viven en React
Están en variables de módulo:
let currentAccessToken: string | null = null;
let currentRefreshToken: string | null = null;
Y el contexto de sesión los empuja ahí con setApiAccessToken() cada vez que cambian. Puede chocar
viniendo de React, pero es lo que permite que apiFetch sea una función suelta, llamable desde un
servicio o un hook, sin necesitar un componente ni un useContext.
La consecuencia a tener presente: el token del módulo y el del estado de React se actualizan por
caminos distintos. Por eso los hooks de datos esperan al accessToken del contexto antes de
lanzar nada — ver useFetch o usePaginatedFetch.
Un único refresh en vuelo, compartido
Cuando una petición recibe un 401, apiFetch intenta renovar el token. Si hay tres peticiones en
vuelo, las tres comparten la misma promesa:
if (!refreshPromise) {
refreshPromise = (async () => { /* ... */ })().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
Esto no es una optimización. El motivo está comentado en el código: el backend rota el refresh token en cada uso y revoca la familia entera de sesiones si se le presenta uno caducado dos veces. Dos refreshes en paralelo usarían el mismo token dos veces y cerrarían la sesión del usuario.
Si alguna vez reescribes esta parte, esa es la invariante que hay que conservar.
Las rutas /auth/* no disparan refresh
if (response.status === 401 && !path.startsWith('/auth/')) {
Un 401 en login, logout o en el propio refresh no significa “token caducado”: significa credenciales incorrectas o sesión ya muerta. Intentar renovar ahí provocaría un bucle.
Si el refresh falla, se avisa al contexto con el handler que registró (sessionExpiredHandler) y la
app vuelve al estado no autenticado. No se reintenta nada más: a esas alturas la sesión ya no existe
en el servidor.
La cabecera que cambia la respuesta del backend
headers.set('X-Client-Type', 'mobile');
Con ella, el backend devuelve el refresh token en el cuerpo; sin ella lo manda en una cookie
httpOnly, que un cliente nativo no puede leer. Se manda en todas las peticiones, y también en la
de refresh, que construye sus cabeceras aparte.
Errores y respuestas vacías
- Todo fallo se convierte en
ApiError, que lleva elstatus— así una pantalla puede distinguir un 422 de un 500 sin parsear texto. - Un
204devuelveundefineden lugar de intentar parsear un cuerpo que no existe.
Los servicios por encima
services/ tiene un fichero por recurso (pets-api.ts, settings-api.ts…) y cada uno son
envoltorios finos sobre apiFetch que solo aportan la ruta y los tipos:
export function listPets(page: number, limit: number) {
return apiFetch<PaginatedResponse<Pet>>(`/pets?page=${page}&limit=${limit}`);
}
Los tipos compartidos viven en services/api-types.ts. Un servicio nuevo se crea cuando aparece un
recurso nuevo de la api, no cuando aparece una pantalla nueva.
Descargar un fichero no pasa por aquí
apiFetch sirve para JSON. Un fichero que hay que guardar —un ZIP, un PDF— se sale de este
camino, y conviene saber por qué antes de intentar forzarlo.
En el servidor esa descarga es un GET con un token en la query, no una llamada autenticada por
cabecera, y la página del lado API explica el porqué.
Desde aquí lo que importa es la consecuencia: hace falta un paso previo que pida el token con
apiFetch, y la descarga en sí la hace otro.
const { token } = await requestDataExport(currentPassword);
await WebBrowser.openBrowserAsync(dataExportDownloadUrl(token));
Se abre el navegador del sistema y no se descarga dentro de la app, y es una decisión de coste,
no de elegancia: expo-file-system es código nativo y añadirlo obliga a un rebuild de EAS, mientras
que expo-web-browser ya está y deja el fichero en la carpeta de descargas del móvil, que es donde
alguien espera encontrarlo.
Lo que esto obliga a hacer en la pantalla
Tres cosas, y las tres se olvidan la primera vez:
- Avisar antes. Que se abra el navegador encima de la app parece un error si nadie lo ha dicho.
- Confirmar al volver.
openBrowserAsyncse resuelve cuando el usuario descarta la pestaña, no cuando arranca la descarga, así que es el momento exacto en que la app todavía no ha dicho nada de lo que acaba de pasar. Ahí va el snackbar. - No afirmar lo que no sabes. Ese
GETno lo hace la app: no puede saber si la descarga terminó. El mensaje dice dónde mirar, no “descargado”.
El spinner del botón, por tanto, cubre sólo la llamada que pide el token — unas décimas de segundo—.
Del rato en que el servidor empaqueta no informa nadie desde la app, y si algún día hiciera falta una
barra de verdad, expo-file-system sólo daría bytes y no porcentaje: un fichero que se streamea no
lleva Content-Length.