Patrones de API y servicios para SmartOrder. Usa esta skill cuando trabajes con endpoints, HttpClient, servicios, o transformadores de datos.
Esta skill define los patrones para trabajar con APIs, servicios y comunicación con el backend en SmartOrder-Glassmorphism.
Componente React
↓
Hook personalizado (useCotizacion)
↓
Servicio (CotizadorService)
↓
HttpClient (fetch wrapper)
↓
Backend API
Principio clave: Los componentes NUNCA llaman directamente a HttpClient. Siempre usan servicios.
Wrapper tipado sobre fetch nativo sin dependencias externas.
class HttpClient {
// GET con query params
async get<T>(url: string, options?: RequestOptions): Promise<T>
// POST con body JSON
async post<T>(url: string, body?: any, options?: RequestOptions): Promise<T>
// PUT completo
async put<T>(url: string, body?: any, options?: RequestOptions): Promise<T>
// PATCH parcial
async patch<T>(url: string, body?: any, options?: RequestOptions): Promise<T>
// DELETE
async delete<T>(url: string, options?: RequestOptions): Promise<T>
// Upload archivo
async upload<T>(url: string, formData: FormData, options?: RequestOptions): Promise<T>
// Download archivo
async download(url: string, filename?: string, options?: RequestOptions): Promise<void>
}
import { httpClient } from '@/lib/http'
// ❌ NO hacer esto en componentes
const response = await httpClient.get('/api/cotizaciones')
// ✅ Usar servicios en su lugar
const response = await cotizadorService.listarCotizaciones()
// Headers automáticos
const defaultHeaders = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` // Si existe
}
// Headers personalizados
await httpClient.get('/endpoint', {
headers: {
'X-Custom-Header': 'value'
}
})
// Opción 1: En la URL
await httpClient.get('/api/cotizaciones?page=1&limit=10')
// Opción 2: En options (recomendado)
await httpClient.get('/api/cotizaciones', {
params: {
page: 1,
limit: 10,
estado: 'activa'
}
})
// Resultado: /api/cotizaciones?page=1&limit=10&estado=activa
try {
const data = await httpClient.get('/endpoint')
} catch (error) {
if (error.response?.status === 404) {
console.error('No encontrado')
} else if (error.response?.status === 401) {
console.error('No autorizado')
} else {
console.error('Error:', error.message)
}
}
Centralización de todas las rutas de la API.
const BASE_URL = 'http://localhost:3000'
export const ENDPOINTS = {
// Cotizaciones
cotizaciones: {
base: '/Cotizador/cotizaciones',
crear: '/Cotizador/cotizaciones',
obtener: (id: string) => `/Cotizador/cotizaciones/${id}`,
actualizar: (id: string) => `/Cotizador/cotizaciones/${id}`,
eliminar: (id: string) => `/Cotizador/cotizaciones/${id}`,
duplicar: (id: string) => `/Cotizador/cotizaciones/${id}/duplicar`,
},
// Artículos
articulos: {
buscar: '/Cotizador/articulos/buscar',
agregar: (cotizId: string) => `/Cotizador/cotizaciones/${cotizId}/articulos`,
actualizar: (cotizId: string, artId: number) =>
`/Cotizador/cotizaciones/${cotizId}/articulos/${artId}`,
eliminar: (cotizId: string, artId: number) =>
`/Cotizador/cotizaciones/${cotizId}/articulos/${artId}`,
},
// Clientes
clientes: {
crear: '/Cotizador/clientes/ocasional',
buscarPorCuit: (cuit: string) => `/Cotizador/clientes/cuit/${cuit}`,
buscarPorCodigo: (codigo: string) => `/Cotizador/clientes/${codigo}`,
},
// Percepciones
percepciones: {
disponibles: '/Cotizador/percepciones/disponibles',
cliente: (codCliente: string) => `/Cotizador/clientes/${codCliente}/percepciones`,
calcular: '/Cotizador/percepciones/calcular',
},
}
// Función helper para construir URL completa
export function buildUrl(path: string): string {
return `${BASE_URL}${path}`
}
import { ENDPOINTS, buildUrl } from '@/api/endpoints'
class CotizadorService {
async obtenerCotizacion(id: string) {
const url = ENDPOINTS.cotizaciones.obtener(id)
return httpClient.get<CotizacionResponse>(url)
}
async buscarArticulos(codigo: string) {
return httpClient.get(ENDPOINTS.articulos.buscar, {
params: { codigo }
})
}
}
Ubicación: src/services/<entidad>Service.ts
import { httpClient } from '@/lib/http'
import { ENDPOINTS } from '@/api/endpoints'
import type {
CotizacionResponse,
CotizacionCreateData,
CotizacionUpdateData,
FiltrosCotizaciones
} from './cotizador.types'
class CotizadorService {
// ============= CRUD BÁSICO =============
/**
* Crea una nueva cotización
*/
async crearCotizacion(data: CotizacionCreateData): Promise<CotizacionResponse> {
const dataTransformada = this.transformarParaBackend(data)
const response = await httpClient.post<CotizacionResponse>(
ENDPOINTS.cotizaciones.crear,
dataTransformada
)
return this.transformarParaFrontend(response)
}
/**
* Obtiene una cotización por ID
*/
async obtenerCotizacion(id: string): Promise<CotizacionResponse> {
const response = await httpClient.get<CotizacionResponse>(
ENDPOINTS.cotizaciones.obtener(id)
)
return this.transformarParaFrontend(response)
}
/**
* Lista cotizaciones con filtros
*/
async listarCotizaciones(
filtros?: FiltrosCotizaciones
): Promise<{ data: CotizacionResponse[]; total: number }> {
const response = await httpClient.get(ENDPOINTS.cotizaciones.base, {
params: filtros
})
return {
data: response.data.map(c => this.transformarParaFrontend(c)),
total: response.total
}
}
/**
* Actualiza una cotización
*/
async actualizarCotizacion(
id: string,
data: CotizacionUpdateData
): Promise<CotizacionResponse> {
const dataTransformada = this.transformarParaBackend(data)
const response = await httpClient.patch<CotizacionResponse>(
ENDPOINTS.cotizaciones.actualizar(id),
dataTransformada
)
return this.transformarParaFrontend(response)
}
/**
* Elimina una cotización
*/
async eliminarCotizacion(id: string, hardDelete = false): Promise<void> {
await httpClient.delete(ENDPOINTS.cotizaciones.eliminar(id), {
params: { hard: hardDelete }
})
}
// ============= TRANSFORMADORES =============
/**
* Transforma datos del frontend al formato del backend
*/
private transformarParaBackend(data: any): any {
return {
cod_cliente: data.codCliente,
razon_social: data.razonSocial,
fecha_cotizacion: data.fechaCotizacion?.toISOString(),
// ... más transformaciones
}
}
/**
* Transforma respuesta del backend al formato del frontend
*/
private transformarParaFrontend(response: any): CotizacionResponse {
return {
idCot: response.id_cot,
nroCot: response.nro_cot,
fechaCotizacion: new Date(response.fecha_cotizacion),
cliente: {
codCliente: response.cliente.cod_cliente,
razonSocial: response.cliente.razon_social,
// ... más transformaciones
},
// ... más campos
}
}
}
// Exportar instancia singleton
export const cotizadorService = new CotizadorService()
export default cotizadorService
crearCotizacion(data)
obtenerCotizacion(id)
listarCotizaciones(filtros)
actualizarCotizacion(id, data)
eliminarCotizacion(id, hardDelete)
duplicarCotizacion(id)
buscarArticulos(codigo)
generarPdf(cotizacionId, opciones)
agregarArticulo(cotizacionId, articulo)
actualizarArticulo(cotizacionId, articuloId, data)
eliminarArticulo(cotizacionId, articuloId)
adquirirBloqueo(cotizacionId)
renovarBloqueo(cotizacionId)
liberarBloqueo(cotizacionId)
crearVersion(cotizacionId, razon)
obtenerVersiones(cotizacionId)
restaurarVersion(cotizacionId, version)
Los transformadores convierten entre el formato del backend (snake_case, nombres de campos específicos) y el frontend (camelCase, nombres estandarizados).
class MiServicio {
// Backend → Frontend
private transformarParaFrontend(backendData: any): FrontendData {
return {
// Convertir snake_case a camelCase
idEntidad: backendData.id_entidad,
nombreCompleto: backendData.nombre_completo,
// Convertir strings a Date
fechaCreacion: new Date(backendData.fecha_creacion),
// Transformar objetos anidados
cliente: {
codigo: backendData.cliente.cod_cliente,
razon: backendData.cliente.razon_social
},
// Transformar arrays
items: backendData.items.map(item => ({
id: item.id_item,
descripcion: item.desc
}))
}
}
// Frontend → Backend
private transformarParaBackend(frontendData: FrontendData): any {
return {
// Convertir camelCase a snake_case
id_entidad: frontendData.idEntidad,
nombre_completo: frontendData.nombreCompleto,
// Convertir Date a string ISO
fecha_creacion: frontendData.fechaCreacion.toISOString(),
// Transformar objetos anidados
cliente: {
cod_cliente: frontendData.cliente.codigo,
razon_social: frontendData.cliente.razon
},
// Transformar arrays
items: frontendData.items.map(item => ({
id_item: item.id,
desc: item.descripcion
}))
}
}
}
// utils/transformadores.ts
/**
* Convierte snake_case a camelCase
*/
export function snakeToCamel(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map(snakeToCamel)
}
return Object.keys(obj).reduce((acc, key) => {
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
acc[camelKey] = snakeToCamel(obj[key])
return acc
}, {} as any)
}
/**
* Convierte camelCase a snake_case
*/
export function camelToSnake(obj: any): any {
if (obj === null || typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return obj.map(camelToSnake)
}
return Object.keys(obj).reduce((acc, key) => {
const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)
acc[snakeKey] = camelToSnake(obj[key])
return acc
}, {} as any)
}
class CotizadorService {
async obtenerCotizacion(id: string): Promise<CotizacionResponse> {
try {
const response = await httpClient.get(ENDPOINTS.cotizaciones.obtener(id))
return this.transformarParaFrontend(response)
} catch (error) {
// Enriquecer el error con contexto
throw new Error(`Error al obtener cotización ${id}: ${error.message}`)
}
}
}
export function useCotizacion() {
const [error, setError] = useState<Error | null>(null)
const toast = useToast()
const cargar = async (id: string) => {
try {
setError(null)
const data = await cotizadorService.obtenerCotizacion(id)
setCotizacion(data)
} catch (err) {
const error = err as Error
setError(error)
toast({
title: 'Error al cargar cotización',
description: error.message,
status: 'error',
duration: 5000,
isClosable: true
})
}
}
return { cargar, error }
}
export function MiComponente() {
const { cargar, error } = useCotizacion()
useEffect(() => {
cargar('123')
}, [])
if (error) {
return (
<Alert status="error">
<AlertIcon />
<AlertTitle>Error!</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)
}
return <div>Contenido</div>
}
interface FiltrosCotizaciones {
page?: number
limit?: number
// ... otros filtros
}
async listarCotizaciones(filtros: FiltrosCotizaciones = {}) {
const { page = 1, limit = 20, ...otrosFiltros } = filtros
const response = await httpClient.get(ENDPOINTS.cotizaciones.base, {
params: {
page,
limit,
...otrosFiltros
}
})
return {
data: response.data.map(this.transformarParaFrontend),
total: response.total,
page: response.page,
totalPages: Math.ceil(response.total / limit)
}
}
export function usePaginacion<T>(
fetchFn: (page: number, limit: number) => Promise<{ data: T[]; total: number }>
) {
const [page, setPage] = useState(1)
const [limit] = useState(20)
const [data, setData] = useState<T[]>([])
const [total, setTotal] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const cargar = async () => {
setIsLoading(true)
try {
const result = await fetchFn(page, limit)
setData(result.data)
setTotal(result.total)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
cargar()
}, [page])
const nextPage = () => setPage(p => p + 1)
const prevPage = () => setPage(p => Math.max(1, p - 1))
const goToPage = (p: number) => setPage(p)
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
isLoading,
nextPage,
prevPage,
goToPage,
refetch: cargar
}
}
// Uso
const { data, page, totalPages, nextPage, prevPage } = usePaginacion(
(page, limit) => cotizadorService.listarCotizaciones({ page, limit })
)
export function useCotizaciones() {
const [cotizaciones, setCotizaciones] = useState<CotizacionResponse[]>([])
// Cache de búsqueda
const buscar = useMemo(() => {
return (termino: string) => {
return cotizaciones.filter(c =>
c.cliente.razonSocial.toLowerCase().includes(termino.toLowerCase())
)
}
}, [cotizaciones])
return { cotizaciones, buscar }
}
import { debounce } from 'lodash'
export function useBusqueda() {
const [resultados, setResultados] = useState([])
const buscar = useMemo(
() =>
debounce(async (termino: string) => {
if (termino.length < 3) return
const results = await cotizadorService.buscarArticulos(termino)
setResultados(results)
}, 300),
[]
)
useEffect(() => {
return () => buscar.cancel()
}, [])
return { resultados, buscar }
}
async uploadArchivo(file: File): Promise<{ url: string }> {
const formData = new FormData()
formData.append('file', file)
formData.append('tipo', 'cotizacion')
return httpClient.upload<{ url: string }>(
ENDPOINTS.archivos.upload,
formData
)
}
// Uso en componente
const handleFileUpload = async (file: File) => {
try {
const result = await cotizadorService.uploadArchivo(file)
console.log('Archivo subido:', result.url)
} catch (error) {
console.error('Error:', error)
}
}
async descargarPdf(cotizacionId: string): Promise<void> {
const filename = `cotizacion_${cotizacionId}.pdf`
await httpClient.download(
ENDPOINTS.cotizaciones.pdf(cotizacionId),
filename
)
}
// Uso
<Button onClick={() => cotizadorService.descargarPdf('123')}>
Descargar PDF
</Button>
// vitest
import { describe, it, expect, vi } from 'vitest'
import { httpClient } from '@/lib/http'
import cotizadorService from './cotizadorService'
// Mock del httpClient
vi.mock('@/lib/http', () => ({
httpClient: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn()
}
}))
describe('CotizadorService', () => {
it('debe obtener una cotización', async () => {
const mockResponse = {
id_cot: 123,
nro_cot: 456
}
vi.mocked(httpClient.get).mockResolvedValue(mockResponse)
const result = await cotizadorService.obtenerCotizacion('123')
expect(result.idCot).toBe(123)
expect(result.nroCot).toBe(456)
expect(httpClient.get).toHaveBeenCalledWith('/Cotizador/cotizaciones/123')
})
})
<servicio>.types.tsendpoints.ts