Migrar al cliente¶
Dos puntos de partida: un frontend con la autenticación y las llamadas escritas a mano, o uno que ya usa el cliente generado por tai-api 0.6 (más abajo).
Desde una autenticación escrita a mano¶
Es lo habitual en un frontend anterior al cliente: un servicio que guarda los tokens en
localStorage y envuelve fetch, un proveedor de sesión con su hook o su store, un componente que
protege las rutas, unas constantes PERMISSIONS y una declaración de rutas que lo junta todo. El
cliente hace lo mismo, generado desde la API. Esta página dice qué se borra, qué se cambia y qué se
queda, siguiendo el protocolo.
Se puede migrar pantalla a pantalla: el cliente y vuestro servicio de autenticación conviven mientras dure, porque los dos hablan con la misma API. Lo único que no puede convivir es la sesión: o la lleva uno o la lleva el otro, así que los pasos 1 a 4 van juntos.
0. Antes de empezar¶
En la API, la sesión con cookie y el cliente, en el .taiapiproject (con "react": true o
"svelte": true, según el frontend):
{
"auth": { "type": "database", "config": { … }, "session": { "transport": "cookie" } },
"frontend": { "output_dir": "../app/frontend/src/lib/api", "react": true }
}
Los permisos que tenéis en vuestras constantes, declarados en la API, en rbac/main.py:
app_permission("gestion_usuarios-access")
app_permission("gestion_usuarios-create")
app_permission("gestion_usuarios-update")
app_permission("gestion_usuarios-delete")
Después, tai-api generate y tai-api rbac push. En el frontend, el proxy /api (ver
Despliegue): sustituye a la variable con la URL de la API (VITE_API_URL…).
1. Los permisos¶
Vuestras constantes PERMISSIONS se borran. Las generadas tienen la misma forma
—PERMISSIONS.<RECURSO>.<ACCIÓN>—, así que basta con cambiar el import:
PERMISSIONS.GESTION_USUARIOS.ACCESS sigue siendo 'gestion_usuarios-access'. La diferencia es
que ahora sale de la API: si alguien renombra o borra un permiso del RBAC, el frontend deja de
compilar en vez de dejar una pantalla inaccesible.
2. El proveedor de la sesión¶
El vuestro se sustituye por el del adaptador, que además recupera la sesión al cargar:
Ver Montar la aplicación.
3. El hook o el store de la sesión¶
| Lo habitual a mano | En la sesión del cliente |
|---|---|
user.username |
user.preferred_username |
user.full_name |
user.name |
user.permissions |
user.permissions |
login(usuario, contraseña) |
login('main', usuario, contraseña): el realm va explícito |
hasRequiredPermission(p) |
hasPermission(p) |
hasRequiredRole(r) |
No hay: decidid por permisos, que son lo que se declara en el RBAC |
Si tenéis muchos componentes con el hook o el store antiguo, podéis dejar uno vuestro que llame al del cliente y devuelva los nombres de antes, e ir cambiándolos con calma.
4. Las rutas y su protección¶
El componente que protegía las rutas se borra, y la declaración de rutas se queda, con pocos cambios:
- import { ProtectedRoute } from '@/auth/components/protected_route'
+ import { defineRoutes } from '@/lib/api/react'
- interface RouteConfig { … }
- export const routes: RouteConfig[] = [
+ export const routes = defineRoutes([
{ path: '/login', element: <LoginPage />, protected: false, enabled: true },
{ path: '/lotes', element: <LotesPage />, icon: Boxes, label: 'Lotes', protected: true, enabled: true },
…
- ]
+ ])
- export function renderRoutes() { … }
Y donde se pintaban:
- <Routes>{renderRoutes()}</Routes>
+ <Routes>{renderRoutes(routes, { layout: <AppLayout />, notFound: <Page404 />, underConstruction: <UnderConstruction /> })}</Routes>
Lo que envolvía a las pantallas protegidas —el sidebar, la cabecera— pasa a un AppLayout con
un <Outlet />, y ese es el layout.
Las comprobaciones que había en cada +page.svelte o en un +layout.svelte —«si no hay
usuario, al login»— se sustituyen por la guarda del layout raíz, accessStore(routes, page), y
la declaración pasa a src/lib/routes.ts con defineRoutes. Ver
Proteger las pantallas.
protected: true y enabled: true se pueden quitar: son lo que vale por defecto.
En un árbol con children, el permiso de cada pantalla se comprueba, también el de los hijos:
ver En árbol: con children.
5. El sidebar y la cabecera¶
Si el sidebar recorría las rutas y pintaba todas las que tuvieran label e icon, también
enseñaba las que el usuario no podía abrir. Y si había una lista de secciones aparte, que repetía
la ruta, el título y el icono de cada pantalla, se borra: el label y el icon van en la ruta, y
el menú sale de la declaración.
El título y las migas: ver El menú y las migas.
6. Las llamadas¶
El fetch envuelto y los servicios que solo construían URLs y copiaban tipos se van
sustituyendo pantalla a pantalla:
- const BASE_URL = `${import.meta.env.VITE_API_URL_BASE}/public/producto`
- const r = await AuthService.authorizedFetchConMeta<ProductoAPI[]>(`${BASE_URL}?limit=20&includes=tipos_producto.tipo_producto`, 'GET')
+ const r = await api.public.producto.findMany({ limit: 20, includes: ['tipos_producto.tipo_producto'] })
+ // r.data[0].tipos_producto[0].tipo_producto.tipo r.pagination?.total
Los tipos escritos a mano ya no se escriben: son ProductoRow, ProductoCreate…, importados de
@/lib/api/public, y el de un registro con relaciones sale de sus includes. Las constantes de
includes pasan de texto de URL a una lista as const, y de ella sale el tipo que reciben los
componentes:
- const INCLUDES_DETALLE = ['includes=producto.tipos_producto.tipo_producto', 'includes=documentos_receta'].join('&')
- export interface RecetaDetalle extends RecetaAPI { producto?: ProductoAPI; documentos_receta?: DocumentoAPI[] }
+ const INCLUDES_DETALLE = ['producto.tipos_producto.tipo_producto', 'documentos_receta'] as const
+ export type RecetaDetalle = RecetaWith<typeof INCLUDES_DETALLE>
+ const receta = await api.public.receta.find(id, { includes: INCLUDES_DETALLE }) // RecetaDetalle
Con eso se van los ?. y los ?? [] sobre las relaciones pedidas, y un include que falta o que pasa
del sexto nivel deja de compilar (Llamadas).
Las agregaciones dejan de tiparse a mano («contrato de /agg, tipado a mano»): agg devuelve
data.sum_cantidad y group.tipo_movimiento con su tipo.
Lo que se queda es vuestra lógica: las funciones que transforman para la pantalla, los cálculos, las validaciones de formulario.
Y las consultas de TanStack se quedan en una línea: ver Una función por pantalla. Lo que se va con ellas:
- Las claves propias (
['lotes', 'list'],MOVIMIENTOS_KEY) y las funciones que las invalidan a mano (invalidarStock): las keys son las depublicKeys, y una mutación invalida por tablas. Lo que un trigger cambia en otra tabla se dice coninvalidate: ['producto']. - Las consultas de varias llamadas se quedan, pero como
publicQuery({ key, tables, queryFn }): declaran las tablas que leen y les llega la invalidación. Las mutaciones compuestas, comopublicMutation(queryClient, { tables, mutationFn }). - El
onSuccessque pisaba la invalidación (useMutation({ ...m.x.create, onSuccess })): el callback va dentro de la factoría,m.x.create({ onSuccess }), y se suma. PATCH+GETcon includes en cada ficha:m.x.update({ includes })lo hace en una petición y deja el registro en la key de la ficha.- El atajo del
in_vacío («si no hay códigos, devuelve{ items: [] }sin llamar»): lo hace el cliente. - Las fechas y los bytes a mano (
toDateInput,aFechaHoraApi,normalizarBytes): sontoApiDate/toApiDateTime/fromApiDateTime/endOfDayyfileToBytes/bytesToObjectUrl.
7. Lo que se borra al terminar¶
- El servicio de autenticación y el proveedor de sesión hechos a mano, con sus interfaces y sus datos de prueba.
- Las constantes
PERMISSIONS. - El componente que protegía las rutas y su función para pintarlas.
- La lista de secciones del menú, si la había.
- Los servicios que solo hacían
fetch. - Los tokens en
localStorage: el cliente no guarda ninguno.
Los mocks¶
Para trabajar sin la API, el sitio es el fetch del cliente: createTaiClient({ fetch: miFetchFalso })
recibe cada petición con su ruta y su método, y puede responder lo que quiera con el formato de la
API ({ status: 'success', data: … }).
Desde el cliente de tai-api 0.6¶
El cliente de la 0.7 rompe compatibilidad (la API, solo en lo que cuenta
Novedades): al regenerar, tsc señala cada sitio que cambia. Lo que
hay que hacer, en el orden en que suele salir.
0. Regenerar¶
Si falla con A012, el bloque frontend activa algo que el package.json no tiene —lo habitual,
"svelte": true en un proyecto de React—: se quita del bloque o se instala. Después,
npm run build.
1. Las mutaciones son factorías¶
- const crear = useMutation(m.lote_producto.create)
+ const crear = useMutation(m.lote_producto.create())
- const crear = useMutation({ ...m.lote_producto.create, onSuccess: cerrarModal }) // pisaba la invalidación
+ const crear = useMutation(m.lote_producto.create({ onSuccess: cerrarModal })) // se suma a ella
- const editar = useMutation(m.receta.update) // releía sin includes: la ficha, sin tocar
+ const editar = useMutation(m.receta.update({ includes: INCLUDES_DETALLE })) // deja la ficha al día
update lleva los includes en la factoría, no en mutate, y su resultado es el registro con ellos
o null (ver TanStack).
2. Las keys¶
publicKeys.<tabla> era el prefijo; ahora es un objeto con la key de cada consulta:
- queryClient.invalidateQueries({ queryKey: publicKeys.lote_producto })
+ publicInvalidate(queryClient, ['lote_producto']) // la tabla y lo que la lee por una relación
+ // o el prefijo a mano: publicKeys.lote_producto.all
Las keys propias (['lotes', 'list']) y las invalidaciones a mano (invalidarStock) pueden irse: una
consulta de varias llamadas es un publicQuery({ key, tables, queryFn }) y le llega la invalidación
de sus tablas (Lo propio).
3. El registro trae lo que se pidió¶
<Tabla>Read ya no tiene relaciones opcionales: son las columnas (<Tabla>Row). El tipo con
relaciones lo da find/findMany a partir de los includes:
- const receta: RecetaRead = await api.public.receta.find(7, { includes: ['producto'] })
- receta.producto?.codigo ?? '—'
+ const receta = await api.public.receta.find(7, { includes: ['producto'] })
+ receta.producto.codigo
- Una constante de includes necesita
as const, y de ella sale el tipo de las props:type RecetaDetalle = RecetaWith<typeof INCLUDES_DETALLE>. Unastring[]no compila. - Una función que recibía
<Tabla>FindManyParamsy se los pasaba afindManyno compila si llevaincludes: ver Una función por pantalla. - Un
placeholderDatao unsetQueryDataa mano tienen que tener el tipo con losincludes. - Las interfaces escritas a mano con relaciones opcionales se borran.
4. La sesión y el adaptador¶
- Del adaptador de React se importa de
@/lib/api/react. Sus ficheros se partieron en componentes y hooks (para Fast Refresh):@/lib/api/react/sessiony@/lib/api/react/routesya no tienen los hooks. - El realm es de tipo
Realm, la unión de los del proyecto:login(realm, …)con unstringsuelto no compila;login('main', …)sí.
5. El modo estricto¶
El cliente manda siempre X-Tai-Strict: 1: un filtro, un order_by o un includes que la API
ignoraba —una errata como in_idd, una relación renombrada— es ahora un TaiApiError 422 con su
field. Se arregla el nombre. Mientras tanto, createTaiClient({ strict: false }) lo apaga.
6. Lo que se puede borrar¶
- Los atajos del
in_vacío («si no hay códigos, devuelve vacío sin llamar»): lo hace el cliente. - Las compensaciones (crear, y si lo siguiente falla, borrar):
api.public.transaction, om.transactioncon TanStack (Varias tablas en una transacción). - Las conversiones de fechas y bytes:
toApiDateTime,fromApiDateTime,toApiDate,endOfDay,fileToBytes,bytesToObjectUrl. - El servicio de
/rbacescrito a mano:api.rbac(realm)(Usuarios). - El
PATCHseguido de unGETen cada ficha:update(…, { returning: true, includes }).
7. Y en la API¶
Al regenerar, las escrituras anidadas piden el permiso de las tablas hijas —una pantalla que guarda
hijos desde el formulario del padre con un rol que solo tiene el permiso del padre recibe 403—, el
PATCH masivo no admite relaciones y un in_ vacío en un filtro de cuerpo no toca ninguna fila.
Qué hacer con cada cosa, en Novedades.
Un doble de QueryClient en los tests (un QueryClientLike propio) necesita ahora
invalidateQueries({ predicate }), removeQueries, setQueryData y clear.