Agent Skill
2/7/2026smartorder-api
Patrones de API y servicios para SmartOrder. Usa esta skill cuando trabajes con endpoints, HttpClient, servicios, o transformadores de datos.
S
samuop
0GitHub Stars
1Views
npx skills add samuop/SmartOrder-Glassmorphism
SKILL.md
| Name | smartorder-api |
| Description | Patrones de API y servicios para SmartOrder. Usa esta skill cuando trabajes con endpoints, HttpClient, servicios, o transformadores de datos. |
name: smartorder-api description: Patrones de API y servicios para SmartOrder. Usa esta skill cuando trabajes con endpoints, HttpClient, servicios, o transformadores de datos. allowed-tools: Read, Grep, Edit, Write
SmartOrder - API & Servicios
Esta skill define los patrones para trabajar con APIs, servicios y comunicación con el backend en SmartOrder-Glassmorphism.
Arquitectura de Capas
Componente React
↓
Hook personalizado (useCotizacion)
↓
Servicio (CotizadorService)
↓
HttpClient (fetch wrapper)
↓
Backend API
Principio clave: Los componentes NUNCA llaman directamente a HttpClient. Siempre usan servicios.
HttpClient (src/lib/http.ts)
Wrapper tipado sobre fetch nativo sin dependencias externas.
Características
- Timeout automático (30s por defecto)
- Headers de autenticación automáticos
- Manejo de query params
- Upload/download de archivos
- Gestión de errores centralizada
- Métodos: GET, POST, PUT, PATCH, DELETE
Métodos Disponibles
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>
}
Ejemplo de Uso Directo (NO RECOMENDADO)
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()
Configuración de Headers
// 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'
}
})
Query Params
// 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
Manejo de Errores
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)
}
}
Endpoints (src/api/endpoints.ts)
Centralización de todas las rutas de la API.
Estructura
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}`
}
Uso en Servicios
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 }
})
}
}
Estructura de Servicios
Clase de Servicio
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
Métodos de Servicio por Categoría
1. CRUD Básico
crearCotizacion(data)
obtenerCotizacion(id)
listarCotizaciones(filtros)
actualizarCotizacion(id, data)
eliminarCotizacion(id, hardDelete)
2. Operaciones Específicas
duplicarCotizacion(id)
buscarArticulos(codigo)
generarPdf(cotizacionId, opciones)
3. Operaciones Relacionadas
agregarArticulo(cotizacionId, articulo)
actualizarArticulo(cotizacionId, articuloId, data)
eliminarArticulo(cotizacionId, articuloId)
4. Concurrencia
adquirirBloqueo(cotizacionId)
renovarBloqueo(cotizacionId)
liberarBloqueo(cotizacionId)
5. Versiones
crearVersion(cotizacionId, razon)
obtenerVersiones(cotizacionId)
restaurarVersion(cotizacionId, version)
Transformadores de Datos
Los transformadores convierten entre el formato del backend (snake_case, nombres de campos específicos) y el frontend (camelCase, nombres estandarizados).
¿Por qué Transformadores?
- Abstracción: Componentes no necesitan conocer el formato del backend
- Migración: Si cambia el backend, solo se modifica el transformador
- Consistencia: Formato único en todo el frontend
Patrón de Transformación
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
}))
}
}
}
Helpers de Transformación
// 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)
}
Manejo de Errores
En Servicios
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}`)
}
}
}
En Hooks
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 }
}
En Componentes
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>
}
Paginación
Request con Paginación
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)
}
}
Hook con Paginación
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 })
)
Caché y Optimización
Cache Simple con useMemo
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 }
}
Debounce en Búsquedas
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 }
}
Upload de Archivos
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)
}
}
Download de Archivos
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>
Testing de Servicios
// 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')
})
})
Checklist para Crear un Nuevo Servicio
- Definir tipos en
<servicio>.types.ts - Agregar endpoints en
endpoints.ts - Crear clase de servicio
- Implementar métodos CRUD básicos
- Agregar transformadores (backend ↔ frontend)
- Implementar métodos específicos
- Manejo de errores robusto
- Exportar instancia singleton
- Documentar métodos con JSDoc
- Crear tests unitarios
Referencias
- HttpClient - Cliente HTTP
- Endpoints - 86 endpoints definidos
- CotizadorService - 643 líneas
- cotizador.types.ts - 378 líneas de tipos
Skills Info
Original Name:smartorder-apiAuthor:samuop
Download