Manual Completo de Vue 3 consumiendo API FastAPI Vue 3 + FastAPI

Implementación de un frontend en Vue 3 para el sistema de inventario con autenticación JWT

🚀 Introducción

Este manual cubre el desarrollo completo de una aplicación Vue 3 que consume la API FastAPI del manual anterior, implementando:

💡 Características Principales

📐 Arquitectura del Proyecto

inventory-frontend/
├── public/                  # Archivos estáticos
├── src/
│   ├── api/                 # Configuración de Axios y endpoints
│   │   ├── auth.js          # Endpoints de autenticación
│   │   └── products.js      # Endpoints de productos
│   ├── components/          # Componentes reutilizables
│   │   ├── Layout.vue       # Layout principal
│   │   ├── Navbar.vue       # Barra de navegación
│   │   └── PrivateRoute.vue # Ruta protegida
│   ├── stores/              # Stores de Pinia
│   │   └── auth.js          # Store de autenticación
│   ├── views/               # Vistas/páginas
│   │   ├── auth/
│   │   │   ├── Login.vue    # Vista de login
│   │   │   └── Register.vue # Vista de registro
│   │   ├── products/
│   │   │   ├── List.vue     # Lista de productos
│   │   │   ├── Create.vue   # Crear producto
│   │   │   └── Edit.vue     # Editar producto
│   │   └── Home.vue         # Vista de inicio
│   ├── App.vue              # Componente principal
│   ├── main.js              # Punto de entrada
│   ├── router.js            # Configuración de rutas
│   └── styles/             # Estilos globales
├── package.json            # Dependencias
└── .env                    # Variables de entorno

1. Configuración del Proyecto

Crear la aplicación Vue

Terminal
# Crear aplicación Vue con Vite
npm create vue@latest inventory-frontend
cd inventory-frontend

# Instalar dependencias básicas
npm install axios pinia vue-router vee-validate @vee-validate/rules jwt-decode

# Opcional: Instalar PrimeVue
npm install primevue primeicons primeflex

# Instalar dependencias de desarrollo para Vee-Validate
npm install @vee-validate/rules --save-dev

Configuración de variables de entorno

.env
VITE_API_BASE_URL=http://localhost:8000
VITE_API_TIMEOUT=5000

⚠️ Importante

En Vite, las variables de entorno para el cliente deben comenzar con VITE_ para ser accesibles en el código.

2. Configuración de Axios

Configuración base de Axios

src/api/axios.js
import axios from 'axios';

const api = axios.create({
    baseURL: import.meta.env.VITE_API_BASE_URL,
    timeout: import.meta.env.VITE_API_TIMEOUT,
});

// Interceptor para añadir el token JWT a las peticiones
api.interceptors.request.use((config) => {
    const token = localStorage.getItem('token');
    if (token) {
        config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
}, (error) => {
    return Promise.reject(error);
});

export default api;

Endpoints de Autenticación

src/api/auth.js
import api from './axios';

export const login = async (credentials) => {
    try {
        const response = await api.post('/token', 
            `username=${credentials.username}&password=${credentials.password}`,
            {
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                }
            }
        );
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const register = async (userData) => {
    try {
        const response = await api.post('/register', userData);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const getCurrentUser = () => {
    const token = localStorage.getItem('token');
    if (!token) return null;
    
    // Decodificar el token para obtener información del usuario
    try {
        const decoded = jwtDecode(token);
        return { username: decoded.sub };
    } catch (error) {
        return null;
    }
};

Endpoints de Productos

src/api/products.js
import api from './axios';

export const getProducts = async () => {
    try {
        const response = await api.get('/products');
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const getProduct = async (id) => {
    try {
        const response = await api.get(`/products/${id}`);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const createProduct = async (productData) => {
    try {
        const response = await api.post('/products', productData);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const updateProduct = async (id, productData) => {
    try {
        const response = await api.put(`/products/${id}`, productData);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

export const deleteProduct = async (id) => {
    try {
        const response = await api.delete(`/products/${id}`);
        return response.data;
    } catch (error) {
        throw error.response.data;
    }
};

3. Sistema de Autenticación

Store de Autenticación con Pinia

src/stores/auth.js
import { defineStore } from 'pinia';
import { login as apiLogin, register as apiRegister, getCurrentUser } from '@/api/auth';
import { useRouter } from 'vue-router';

export const useAuthStore = defineStore('auth', {
    state: () => ({
        user: null,
        isAuthenticated: false,
        loading: false,
        error: null
    }),
    actions: {
        async initialize() {
            this.loading = true;
            try {
                const user = getCurrentUser();
                if (user) {
                    this.user = user;
                    this.isAuthenticated = true;
                }
            } catch (error) {
                this.error = error.message;
            } finally {
                this.loading = false;
            }
        },
        async login(credentials) {
            this.loading = true;
            this.error = null;
            try {
                const data = await apiLogin(credentials);
                localStorage.setItem('token', data.access_token);
                this.user = getCurrentUser();
                this.isAuthenticated = true;
                return true;
            } catch (error) {
                this.error = error.detail || 'Error al iniciar sesión';
                return false;
            } finally {
                this.loading = false;
            }
        },
        async register(userData) {
            this.loading = true;
            this.error = null;
            try {
                await apiRegister(userData);
                return true;
            } catch (error) {
                this.error = error.detail || 'Error al registrar usuario';
                return false;
            } finally {
                this.loading = false;
            }
        },
        logout() {
            localStorage.removeItem('token');
            this.user = null;
            this.isAuthenticated = false;
            const router = useRouter();
            router.push('/login');
        }
    }
});

Componente de Ruta Privada

src/components/PrivateRoute.vue
<script setup>
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';

const authStore = useAuthStore();
const router = useRouter();

const isAuthenticated = computed(() => authStore.isAuthenticated);

if (!isAuthenticated.value) {
    router.replace('/login');
}
</script>

<template>
    <slot v-if="isAuthenticated" />
    <div v-else class="text-center p-8">
        Cargando...
    </div>
</template>

Configuración de Rutas

src/router.js
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import Home from '@/views/Home.vue';
import Login from '@/views/auth/Login.vue';
import Register from '@/views/auth/Register.vue';
import ProductList from '@/views/products/List.vue';
import ProductCreate from '@/views/products/Create.vue';
import ProductEdit from '@/views/products/Edit.vue';

const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [
        {
            path: '/',
            name: 'home',
            component: Home
        },
        {
            path: '/login',
            name: 'login',
            component: Login,
            meta: { requiresGuest: true }
        },
        {
            path: '/register',
            name: 'register',
            component: Register,
            meta: { requiresGuest: true }
        },
        {
            path: '/products',
            name: 'products',
            component: ProductList,
            meta: { requiresAuth: true }
        },
        {
            path: '/products/create',
            name: 'products.create',
            component: ProductCreate,
            meta: { requiresAuth: true }
        },
        {
            path: '/products/:id/edit',
            name: 'products.edit',
            component: ProductEdit,
            meta: { requiresAuth: true }
        }
    ]
});

router.beforeEach(async (to, from, next) => {
    const authStore = useAuthStore();
    
    if (!authStore.isAuthenticated && localStorage.getItem('token')) {
        await authStore.initialize();
    }

    if (to.meta.requiresAuth && !authStore.isAuthenticated) {
        next({ name: 'login' });
    } else if (to.meta.requiresGuest && authStore.isAuthenticated) {
        next({ name: 'home' });
    } else {
        next();
    }
});

export default router;

4. CRUD de Productos

Lista de Productos

src/views/products/List.vue
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { getProducts, deleteProduct } from '@/api/products';

const products = ref([]);
const loading = ref(true);
const error = ref(null);
const router = useRouter();

const fetchProducts = async () => {
    try {
        products.value = await getProducts();
    } catch (err) {
        error.value = err.detail || 'Error al cargar productos';
    } finally {
        loading.value = false;
    }
};

const handleDelete = async (id) => {
    if (confirm('¿Estás seguro de eliminar este producto?')) {
        try {
            await deleteProduct(id);
            products.value = products.value.filter(p => p.id !== id);
        } catch (err) {
            error.value = err.detail || 'Error al eliminar producto';
        }
    }
};

onMounted(() => {
    fetchProducts();
});
</script>

<template>
    <div>
        <h1>Lista de Productos</h1>
        
        <div v-if="loading" class="text-center p-8">
            Cargando productos...
        </div>
        
        <div v-else-if="error" class="text-red-500 p-4">
            {{ error }}
        </div>
        
        <div v-else>
            <button @click="router.push('/products/create')">
                Crear Nuevo Producto
            </button>
            
            <table>
                <thead>
                    <tr>
                        <th>Nombre</th>
                        <th>Precio</th>
                        <th>Stock</th>
                        <th>Acciones</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="product in products" :key="product.id">
                        <td>{{ product.name }}</td>
                        <td>${{ product.price }}</td>
                        <td>{{ product.stock }}</td>
                        <td>
                            <button @click="router.push(`/products/${product.id}/edit`)">
                                Editar
                            </button>
                            <button @click="handleDelete(product.id)">
                                Eliminar
                            </button>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
</template>

Formulario de Producto

src/views/products/Create.vue
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useForm, useField } from 'vee-validate';
import * as yup from 'yup';
import { createProduct } from '@/api/products';

const router = useRouter();
const error = ref(null);

// Esquema de validación
const schema = yup.object({
    name: yup.string().required('El nombre es requerido'),
    price: yup.number()
        .required('El precio es requerido')
        .positive('El precio debe ser positivo'),
    stock: yup.number()
        .integer('El stock debe ser un número entero')
        .min(0, 'El stock no puede ser negativo'),
    description: yup.string(),
    category: yup.string(),
});

// Configuración del formulario
const { handleSubmit } = useForm({
    validationSchema: schema,
});

// Campos del formulario
const { value: name, errorMessage: nameError } = useField('name');
const { value: description } = useField('description');
const { value: price, errorMessage: priceError } = useField('price');
const { value: stock, errorMessage: stockError } = useField('stock');
const { value: category } = useField('category');

// Envío del formulario
const onSubmit = handleSubmit(async (values) => {
    try {
        await createProduct(values);
        router.push('/products');
    } catch (err) {
        error.value = err.detail || 'Error al crear producto';
    }
});
</script>

<template>
    <div>
        <h1>Crear Producto</h1>
        
        <form @submit="onSubmit">
            <div>
                <label>Nombre</label>
                <input v-model="name" type="text" />
                <span v-if="nameError" class="text-red-500">{{ nameError }}</span>
            </div>
            
            <div>
                <label>Descripción</label>
                <textarea v-model="description"></textarea>
            </div>
            
            <div>
                <label>Precio</label>
                <input v-model="price" type="number" step="0.01" />
                <span v-if="priceError" class="text-red-500">{{ priceError }}</span>
            </div>
            
            <div>
                <label>Stock</label>
                <input v-model="stock" type="number" />
                <span v-if="stockError" class="text-red-500">{{ stockError }}</span>
            </div>
            
            <div>
                <label>Categoría</label>
                <input v-model="category" type="text" />
            </div>
            
            <button type="submit">Guardar</button>
            <button type="button" @click="router.push('/products')">
                Cancelar
            </button>
            
            <div v-if="error" class="text-red-500 mt-4">
                {{ error }}
            </div>
        </form>
    </div>
</template>

5. Login y Registro

Componente de Login

src/views/auth/Login.vue
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useForm, useField } from 'vee-validate';
import * as yup from 'yup';
import { useAuthStore } from '@/stores/auth';

const router = useRouter();
const authStore = useAuthStore();

// Esquema de validación
const schema = yup.object({
    username: yup.string().required('Usuario es requerido'),
    password: yup.string().required('Contraseña es requerida'),
});

// Configuración del formulario
const { handleSubmit } = useForm({
    validationSchema: schema,
});

// Campos del formulario
const { value: username, errorMessage: usernameError } = useField('username');
const { value: password, errorMessage: passwordError } = useField('password');

// Envío del formulario
const onSubmit = handleSubmit(async (values) => {
    const success = await authStore.login(values);
    if (success) {
        router.push('/products');
    }
});
</script>

<template>
    <div>
        <h1>Iniciar Sesión</h1>
        
        <form @submit="onSubmit">
            <div>
                <label>Usuario</label>
                <input v-model="username" type="text" />
                <span v-if="usernameError" class="text-red-500">{{ usernameError }}</span>
            </div>
            
            <div>
                <label>Contraseña</label>
                <input v-model="password" type="password" />
                <span v-if="passwordError" class="text-red-500">{{ passwordError }}</span>
            </div>
            
            <button type="submit">Ingresar</button>
            
            <div v-if="authStore.error" class="text-red-500 mt-4">
                {{ authStore.error }}
            </div>
        </form>
        
        <p>
            ¿No tienes cuenta? <router-link to="/register">Regístrate aquí</router-link>
        </p>
    </div>
</template>

Componente de Registro

src/views/auth/Register.vue
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useForm, useField } from 'vee-validate';
import * as yup from 'yup';
import { useAuthStore } from '@/stores/auth';

const router = useRouter();
const authStore = useAuthStore();
const success = ref(false);

// Esquema de validación
const schema = yup.object({
    username: yup.string()
        .required('Usuario es requerido')
        .min(3, 'Usuario debe tener al menos 3 caracteres'),
    email: yup.string()
        .email('Email inválido')
        .required('Email es requerido'),
    password: yup.string()
        .required('Contraseña es requerida')
        .min(6, 'Contraseña debe tener al menos 6 caracteres'),
});

// Configuración del formulario
const { handleSubmit } = useForm({
    validationSchema: schema,
});

// Campos del formulario
const { value: username, errorMessage: usernameError } = useField('username');
const { value: email, errorMessage: emailError } = useField('email');
const { value: password, errorMessage: passwordError } = useField('password');

// Envío del formulario
const onSubmit = handleSubmit(async (values) => {
    const registered = await authStore.register(values);
    if (registered) {
        success.value = true;
        setTimeout(() => router.push('/login'), 2000);
    }
});
</script>

<template>
    <div v-if="!success">
        <h1>Registro</h1>
        
        <form @submit="onSubmit">
            <div>
                <label>Usuario</label>
                <input v-model="username" type="text" />
                <span v-if="usernameError" class="text-red-500">{{ usernameError }}</span>
            </div>
            
            <div>
                <label>Email</label>
                <input v-model="email" type="email" />
                <span v-if="emailError" class="text-red-500">{{ emailError }}</span>
            </div>
            
            <div>
                <label>Contraseña</label>
                <input v-model="password" type="password" />
                <span v-if="passwordError" class="text-red-500">{{ passwordError }}</span>
            </div>
            
            <button type="submit">Registrarse</button>
            
            <div v-if="authStore.error" class="text-red-500 mt-4">
                {{ authStore.error }}
            </div>
        </form>
        
        <p>
            ¿Ya tienes cuenta? <router-link to="/login">Inicia sesión aquí</router-link>
        </p>
    </div>
    
    <div v-else class="text-center p-8">
        <h2>Registro Exitoso</h2>
        <p>Redirigiendo a la página de login...</p>
    </div>
</template>

6. Componentes Reutilizables

Layout Principal

src/components/Layout.vue
<script setup>
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';

const authStore = useAuthStore();
const router = useRouter();

const handleLogout = () => {
    authStore.logout();
};
</script>

<template>
    <div>
        <header>
            <nav>
                <router-link to="/">Inicio</router-link>
                
                <template v-if="authStore.isAuthenticated">
                    <router-link to="/products">Productos</router-link>
                    <span>Bienvenido, {{ authStore.user?.username }}</span>
                    <button @click="handleLogout">Cerrar sesión</button>
                </template>
                
                <template v-else>
                    <router-link to="/login">Iniciar sesión</router-link>
                    <router-link to="/register">Registrarse</router-link>
                </template>
            </nav>
        </header>
        
        <main>
            <slot />
        </main>
        
        <footer>
            <p>Sistema de Inventario © {{ new Date().getFullYear() }}</p>
        </footer>
    </div>
</template>

Navbar con PrimeVue (Opcional)

src/components/Navbar.vue
<script setup>
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
import Button from 'primevue/button';
import Avatar from 'primevue/avatar';

const authStore = useAuthStore();
const router = useRouter();

const handleLogout = () => {
    authStore.logout();
};
</script>

<template>
    <div class="card">
        <Menubar :model="items">
            <template #start>
                <router-link to="/">
                    <span class="font-bold">Inventario App</span>
                </router-link>
            </template>
            
            <template #end>
                <template v-if="authStore.isAuthenticated">
                    <Button 
                        label="Productos" 
                        text 
                        @click="router.push('/products')" 
                    />
                    
                    <Avatar 
                        :label="authStore.user?.username?.charAt(0).toUpperCase()" 
                        class="mr-2" 
                        size="normal" 
                        shape="circle" 
                    />
                    
                    <Button 
                        label="Cerrar sesión" 
                        text 
                        @click="handleLogout" 
                    />
                </template>
                
                <template v-else>
                    <Button 
                        label="Iniciar sesión" 
                        text 
                        @click="router.push('/login')" 
                    />
                    <Button 
                        label="Registrarse" 
                        text 
                        @click="router.push('/register')" 
                    />
                </template>
            </template>
        </Menubar>
    </div>
</template>

7. Ejecución y Pruebas

Ejecutar la Aplicación

Terminal
# Ejecutar el frontend
npm run dev

# Ejecutar el backend (desde el directorio del proyecto FastAPI)
uvicorn app.main:app --reload

Flujo de Trabajo

1. Usuario visita la aplicación

2. Si no está autenticado, es redirigido a /login

3. Después de login exitoso:

- Token JWT se almacena en localStorage

- Estado de autenticación se actualiza en Pinia

- Usuario es redirigido a /products

4. En /products:

- Se hace petición GET a /products

- Axios añade automáticamente el token en el header

- Se muestra la lista de productos

5. Para operaciones CRUD:

- Cada acción dispara la petición correspondiente

- El estado de la UI se actualiza según la respuesta

Pruebas de Componentes

🔍 Ejemplo de prueba con Vitest

src/__tests__/Login.spec.js
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import Login from '@/views/auth/Login.vue';
import { useAuthStore } from '@/stores/auth';

describe('Login Component', () => {
    it('renders login form', () => {
        const wrapper = mount(Login, {
            global: {
                plugins: [createTestingPinia()],
            },
        });
        
        expect(wrapper.find('input[type="text"]').exists()).toBe(true);
        expect(wrapper.find('input[type="password"]').exists()).toBe(true);
        expect(wrapper.find('button[type="submit"]').text()).toBe('Ingresar');
    });

    it('calls login action on submit', async () => {
        const wrapper = mount(Login, {
            global: {
                plugins: [createTestingPinia({
                    stubActions: false,
                })],
            },
        });
        
        const authStore = useAuthStore();
        authStore.login = vi.fn();
        
        await wrapper.find('input[type="text"]').setValue('testuser');
        await wrapper.find('input[type="password"]').setValue('password');
        await wrapper.find('form').trigger('submit');
        
        expect(authStore.login).toHaveBeenCalledWith({
            username: 'testuser',
            password: 'password'
        });
    });
});