Autenticación
POST /api/auth/register - Registrar nuevo usuario
POST /api/auth/login - Iniciar sesión
POST /api/auth/refresh - Renovar access token
POST /api/auth/logout - Cerrar sesión
Crea una API REST production-ready con autenticación JWT, validación, testing y deployment
Construye una API REST production-ready con autenticación JWT, validación de datos, testing completo y deployment en la nube. Este tutorial te guiará paso a paso en la creación de un backend robusto y escalable.
Tiempo estimado: 4-6 horas | Nivel: Intermedio | Technologías: Node.js, Express, PostgreSQL, Prisma, JWT
Crearás una API REST completa con las siguientes características:
Requisitos previos: Antes de comenzar, asegúrate de tener:
| Tecnología | Propósito |
|---|---|
| Node.js 20+ | Runtime de JavaScript |
| Express 4 | Framework web minimalista |
| Prisma | ORM type-safe para PostgreSQL |
| Zod | Validación de esquemas |
| jsonwebtoken | Autenticación JWT |
| bcrypt | Hashing de contraseñas |
| Vitest | Testing framework |
| Railway | Plataforma de deployment |
rest-api/├── src/│ ├── config/│ │ └── database.ts # Configuración de Prisma│ ├── controllers/│ │ ├── auth.controller.ts # Lógica de autenticación│ │ └── user.controller.ts # Lógica de usuarios│ ├── middleware/│ │ ├── auth.middleware.ts # Verificación JWT│ │ ├── error.middleware.ts # Manejo de errores│ │ └── validate.middleware.ts # Validación con Zod│ ├── models/│ │ └── schemas.ts # Esquemas Zod│ ├── routes/│ │ ├── auth.routes.ts # Rutas de autenticación│ │ └── user.routes.ts # Rutas de usuarios│ ├── types/│ │ └── express.d.ts # Tipos de Express│ ├── utils/│ │ └── jwt.ts # Utilidades JWT│ └── server.ts # Entry point├── tests/│ ├── unit/ # Tests unitarios│ └── integration/ # Tests de integración├── prisma/│ └── schema.prisma # Esquema de base de datos├── .env.example # Variables de entorno ejemplo├── package.json # Dependencias└── tsconfig.json # Configuración TypeScriptComenzamos creando el proyecto con TypeScript y las dependencias necesarias.
mkdir rest-api && cd rest-apinpm init -y# Dependencias de producciónnpm install express cors dotenv bcrypt jsonwebtoken
# Dependencias de desarrollonpm install -D typescript @types/node @types/express @types/cors @types/bcrypt @types/jsonwebtoken prisma vitest @vitest/coverage-v8 tsxnpx tsc --init{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "moduleResolution": "node", "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]}{ "name": "rest-api", "version": "1.0.0", "description": "REST API completa con Express y PostgreSQL", "main": "dist/server.js", "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc", "start": "node dist/server.js", "test": "vitest", "test:coverage": "vitest --coverage", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio" }, "keywords": ["express", "typescript", "postgresql", "api"], "author": "", "license": "MIT"}Verificación: Ejecuta npm run build para confirmar que TypeScript compila correctamente.
Prisma es un ORM type-safe que facilita trabajar con bases de datos.
npm install prisma @prisma/clientnpx prisma init# DatabaseDATABASE_URL="postgresql://user:password@localhost:5432/rest_api?schema=public"
# JWTJWT_SECRET="your-super-secret-jwt-key-change-this"JWT_REFRESH_SECRET="your-super-secret-refresh-key-change-this"JWT_EXPIRES_IN="15m"JWT_REFRESH_EXPIRES_IN="7d"
# ServerPORT=3000NODE_ENV="development"Importante: Copia .env.example a .env y actualiza los valores con tus credenciales reales.
generator client { provider = "prisma-client-js"}
datasource db { provider = "postgresql" url = env("DATABASE_URL")}
model User { id String @id @default(uuid()) email String @unique password String name String isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
refreshTokens RefreshToken[]}
model RefreshToken { id String @id @default(uuid()) token String @unique userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) expiresAt DateTime createdAt DateTime @default(now())
@@index([userId])}npx prisma migrate dev --name init¿Qué hace este comando? Crea la base de datos, las tablas y genera el cliente de Prisma type-safe.
Zod nos permite validar datos de forma type-safe.
import { z } from 'zod';
// Esquema de registroexport const registerSchema = z.object({ email: z.string().email('Email inválido'), password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),});
// Esquema de loginexport const loginSchema = z.object({ email: z.string().email('Email inválido'), password: z.string().min(1, 'La contraseña es requerida'),});
// Esquema de actualización de usuarioexport const updateUserSchema = z.object({ name: z.string().min(2).optional(), email: z.string().email().optional(),}).partial();
// Esquema de refresh tokenexport const refreshTokenSchema = z.object({ refreshToken: z.string().min(1, 'Refresh token es requerido'),});
// Tipos inferidosexport type RegisterInput = z.infer<typeof registerSchema>;export type LoginInput = z.infer<typeof loginSchema>;export type UpdateUserInput = z.infer<typeof updateUserSchema>;export type RefreshTokenInput = z.infer<typeof refreshTokenSchema>;npm install zodimport { PrismaClient } from '@prisma/client';
// Singleton pattern para Prisma Clientconst globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], });
if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = prisma;}
export default prisma;import express, { Application } from 'express';import cors from 'cors';import dotenv from 'dotenv';import authRoutes from './routes/auth.routes';import userRoutes from './routes/user.routes';import errorHandler from './middleware/error.middleware';
dotenv.config();
const app: Application = express();const PORT = process.env.PORT || 3000;
// Middlewareapp.use(cors());app.use(express.json());app.use(express.urlencoded({ extended: true }));
// Rutasapp.use('/api/auth', authRoutes);app.use('/api/users', userRoutes);
// Health checkapp.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() });});
// Error handler (debe ser último)app.use(errorHandler);
// Iniciar servidorapp.listen(PORT, () => { console.log(`🚀 Servidor corriendo en http://localhost:${PORT}`); console.log(`📚 Ambiente: ${process.env.NODE_ENV}`);});import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'secret';const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'refresh-secret';const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
export interface TokenPayload { userId: string; email: string;}
export const generateAccessToken = (payload: TokenPayload): string => { return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });};
export const generateRefreshToken = (payload: TokenPayload): string => { return jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN });};
export const verifyAccessToken = (token: string): TokenPayload => { return jwt.verify(token, JWT_SECRET) as TokenPayload;};
export const verifyRefreshToken = (token: string): TokenPayload => { return jwt.verify(token, JWT_REFRESH_SECRET) as TokenPayload;};
export const calculateRefreshTokenExpiry = (): Date => { const days = parseInt(JWT_REFRESH_EXPIRES_IN) || 7; return new Date(Date.now() + days * 24 * 60 * 60 * 1000);};import { Request, Response, NextFunction } from 'express';import { verifyAccessToken, TokenPayload } from '../utils/jwt';
// Extender el tipo Request de Expressdeclare global { namespace Express { interface Request { user?: TokenPayload; } }}
export const authenticate = async ( req: Request, res: Response, next: NextFunction): Promise<void> => { try { const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) { res.status(401).json({ error: 'Token no proporcionado' }); return; }
const token = authHeader.split(' ')[1]; const payload = verifyAccessToken(token);
req.user = payload; next(); } catch (error) { res.status(401).json({ error: 'Token inválido o expirado' }); }};
export const optionalAuth = async ( req: Request, res: Response, next: NextFunction): Promise<void> => { try { const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) { const token = authHeader.split(' ')[1]; const payload = verifyAccessToken(token); req.user = payload; }
next(); } catch { next(); // Continuar sin autenticación }};import { Request, Response, NextFunction } from 'express';import { AnyZodObject, ZodError } from 'zod';
export const validate = (schema: AnyZodObject) => async (req: Request, res: Response, next: NextFunction): Promise<void> => { try { await schema.parseAsync({ body: req.body, query: req.query, params: req.params, }); next(); } catch (error) { if (error instanceof ZodError) { res.status(400).json({ error: 'Error de validación', details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message, })), }); } else { res.status(500).json({ error: 'Error interno del servidor' }); } } };import { Request, Response, NextFunction } from 'express';
export class AppError extends Error { constructor( public message: string, public statusCode: number = 500, public isOperational: boolean = true ) { super(message); Object.setPrototypeOf(this, AppError.prototype); }}
export const errorHandler = ( err: Error, req: Request, res: Response, next: NextFunction): void => { if (err instanceof AppError) { res.status(err.statusCode).json({ error: err.message, ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), }); return; }
// Error de Prisma if (err.name === 'PrismaClientKnownRequestError') { res.status(400).json({ error: 'Error de base de datos', details: err.message, }); return; }
// Error genérico console.error('Error no manejado:', err); res.status(500).json({ error: 'Error interno del servidor', ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), });};import { Request, Response } from 'express';import bcrypt from 'bcrypt';import prisma from '../config/database';import { generateAccessToken, generateRefreshToken, calculateRefreshTokenExpiry,} from '../utils/jwt';import { AppError } from '../middleware/error.middleware';
export const register = async (req: Request, res: Response): Promise<void> => { const { email, password, name } = req.body;
try { // Verificar si el usuario ya existe const existingUser = await prisma.user.findUnique({ where: { email } }); if (existingUser) { throw new AppError('El email ya está registrado', 400); }
// Hash de contraseña const hashedPassword = await bcrypt.hash(password, 10);
// Crear usuario const user = await prisma.user.create({ data: { email, password: hashedPassword, name, }, select: { id: true, email: true, name: true, isActive: true, createdAt: true, }, });
// Generar tokens const accessToken = generateAccessToken({ userId: user.id, email: user.email }); const refreshToken = generateRefreshToken({ userId: user.id, email: user.email });
// Guardar refresh token await prisma.refreshToken.create({ data: { token: refreshToken, userId: user.id, expiresAt: calculateRefreshTokenExpiry(), }, });
res.status(201).json({ user, accessToken, refreshToken, }); } catch (error) { if (error instanceof AppError) { res.status(error.statusCode).json({ error: error.message }); return; } res.status(500).json({ error: 'Error al registrar usuario' }); }};
export const login = async (req: Request, res: Response): Promise<void> => { const { email, password } = req.body;
try { // Buscar usuario const user = await prisma.user.findUnique({ where: { email } }); if (!user) { throw new AppError('Credenciales inválidas', 401); }
// Verificar contraseña const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { throw new AppError('Credenciales inválidas', 401); }
// Verificar si está activo if (!user.isActive) { throw new AppError('Usuario inactivo', 403); }
// Generar tokens const accessToken = generateAccessToken({ userId: user.id, email: user.email }); const refreshToken = generateRefreshToken({ userId: user.id, email: user.email });
// Guardar refresh token await prisma.refreshToken.create({ data: { token: refreshToken, userId: user.id, expiresAt: calculateRefreshTokenExpiry(), }, });
res.json({ user: { id: user.id, email: user.email, name: user.name, isActive: user.isActive, }, accessToken, refreshToken, }); } catch (error) { if (error instanceof AppError) { res.status(error.statusCode).json({ error: error.message }); return; } res.status(500).json({ error: 'Error al iniciar sesión' }); }};
export const refreshToken = async (req: Request, res: Response): Promise<void> => { const { refreshToken } = req.body;
try { // Buscar refresh token en BD const tokenRecord = await prisma.refreshToken.findUnique({ where: { token: refreshToken }, include: { user: true }, });
if (!tokenRecord || tokenRecord.expiresAt < new Date()) { throw new AppError('Refresh token inválido o expirado', 401); }
if (!tokenRecord.user.isActive) { throw new AppError('Usuario inactivo', 403); }
// Generar nuevos tokens const accessToken = generateAccessToken({ userId: tokenRecord.user.id, email: tokenRecord.user.email, }); const newRefreshToken = generateRefreshToken({ userId: tokenRecord.user.id, email: tokenRecord.user.email, });
// Actualizar refresh token await prisma.refreshToken.update({ where: { id: tokenRecord.id }, data: { token: newRefreshToken, expiresAt: calculateRefreshTokenExpiry(), }, });
res.json({ accessToken, refreshToken: newRefreshToken, }); } catch (error) { if (error instanceof AppError) { res.status(error.statusCode).json({ error: error.message }); return; } res.status(500).json({ error: 'Error al refrescar token' }); }};
export const logout = async (req: Request, res: Response): Promise<void> => { const { refreshToken } = req.body;
try { await prisma.refreshToken.delete({ where: { token: refreshToken } }); res.json({ message: 'Sesión cerrada exitosamente' }); } catch { res.json({ message: 'Sesión cerrada' }); // No fallar si el token no existe }};import { Request, Response } from 'express';import prisma from '../config/database';import { AppError } from '../middleware/error.middleware';
export const getProfile = async (req: Request, res: Response): Promise<void> => { try { const user = await prisma.user.findUnique({ where: { id: req.user!.userId }, select: { id: true, email: true, name: true, isActive: true, createdAt: true, updatedAt: true, }, });
if (!user) { throw new AppError('Usuario no encontrado', 404); }
res.json({ user }); } catch (error) { if (error instanceof AppError) { res.status(error.statusCode).json({ error: error.message }); return; } res.status(500).json({ error: 'Error al obtener perfil' }); }};
export const updateProfile = async (req: Request, res: Response): Promise<void> => { const { name, email } = req.body;
try { // Verificar si email ya está en uso if (email) { const existingUser = await prisma.user.findFirst({ where: { email, NOT: { id: req.user!.userId }, }, });
if (existingUser) { throw new AppError('El email ya está en uso', 400); } }
const user = await prisma.user.update({ where: { id: req.user!.userId }, data: { name, email }, select: { id: true, email: true, name: true, isActive: true, updatedAt: true, }, });
res.json({ user }); } catch (error) { if (error instanceof AppError) { res.status(error.statusCode).json({ error: error.message }); return; } res.status(500).json({ error: 'Error al actualizar perfil' }); }};
export const deleteAccount = async (req: Request, res: Response): Promise<void> => { try { await prisma.user.delete({ where: { id: req.user!.userId } }); res.json({ message: 'Cuenta eliminada exitosamente' }); } catch (error) { res.status(500).json({ error: 'Error al eliminar cuenta' }); }};import { Router } from 'express';import * as authController from '../controllers/auth.controller';import { validate } from '../middleware/validate.middleware';import { registerSchema, loginSchema, refreshTokenSchema,} from '../models/schemas';
const router = Router();
// Registrorouter.post('/register', validate(registerSchema), authController.register);
// Loginrouter.post('/login', validate(loginSchema), authController.login);
// Refresh tokenrouter.post('/refresh', validate(refreshTokenSchema), authController.refreshToken);
// Logoutrouter.post('/logout', authController.logout);
export default router;import { Router } from 'express';import * as userController from '../controllers/user.controller';import { authenticate } from '../middleware/auth.middleware';import { validate } from '../middleware/validate.middleware';import { updateUserSchema } from '../models/schemas';
const router = Router();
// Todas las rutas requieren autenticaciónrouter.use(authenticate);
// Perfilrouter.get('/profile', userController.getProfile);
// Actualizar perfilrouter.patch('/profile', validate(updateUserSchema), userController.updateProfile);
// Eliminar cuentarouter.delete('/profile', userController.deleteAccount);
export default router;import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globals: true, environment: 'node', coverage: { provider: 'v8', reporter: ['text', 'html'], exclude: ['node_modules/', 'dist/', 'tests/'], }, },});import { describe, it, expect, beforeEach, vi } from 'vitest';import bcrypt from 'bcrypt';import * as authController from '../../src/controllers/auth.controller';import prisma from '../../src/config/database';
// Mock de Prismavi.mock('../../src/config/database', () => ({ default: { user: { findUnique: vi.fn(), create: vi.fn(), }, refreshToken: { create: vi.fn(), }, },}));
// Mock de bcryptvi.mock('bcrypt', () => ({ default: { hash: vi.fn(), compare: vi.fn(), },}));
describe('Auth Controller', () => { let mockReq: any; let mockRes: any;
beforeEach(() => { mockReq = { body: {}, }; mockRes = { status: vi.fn().mockReturnThis(), json: vi.fn(), }; vi.clearAllMocks(); });
describe('register', () => { it('debería registrar un nuevo usuario', async () => { mockReq.body = { email: 'test@example.com', password: 'password123', name: 'Test User', };
vi.mocked(prisma.user.findUnique).mockResolvedValue(null); vi.mocked(bcrypt.hash).mockResolvedValue('hashedPassword'); vi.mocked(prisma.user.create).mockResolvedValue({ id: '1', email: 'test@example.com', name: 'Test User', isActive: true, createdAt: new Date(), });
await authController.register(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201); expect(mockRes.json).toHaveBeenCalledWith( expect.objectContaining({ user: expect.objectContaining({ email: 'test@example.com', }), }) ); });
it('debería rechazar email duplicado', async () => { mockReq.body = { email: 'test@example.com', password: 'password123', name: 'Test User', };
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: '1', email: 'test@example.com', } as any);
await authController.register(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400); expect(mockRes.json).toHaveBeenCalledWith( expect.objectContaining({ error: 'El email ya está registrado', }) ); }); });});import { describe, it, expect, beforeAll, afterAll } from 'vitest';import request from 'supertest';import express, { Application } from 'express';import authRoutes from '../../src/routes/auth.routes';import { errorHandler } from '../../src/middleware/error.middleware';
describe('Auth Integration Tests', () => { let app: Application;
beforeAll(() => { app = express(); app.use(express.json()); app.use('/api/auth', authRoutes); app.use(errorHandler); });
describe('POST /api/auth/register', () => { it('debería registrar un usuario', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: `test${Date.now()}@example.com`, password: 'password123', name: 'Test User', }) .expect(201);
expect(response.body).toHaveProperty('user'); expect(response.body).toHaveProperty('accessToken'); expect(response.body).toHaveProperty('refreshToken'); });
it('debería rechazar email inválido', async () => { const response = await request(app) .post('/api/auth/register') .send({ email: 'invalid-email', password: 'password123', name: 'Test User', }) .expect(400);
expect(response.body).toHaveProperty('error'); }); });});Consejo: Para tests de integración, usa una base de datos separada o contenedores Docker para no afectar tus datos de desarrollo.
npm run devcurl -X POST http://localhost:3000/api/auth/register \ -H "Content-Type: application/json" \ -d '{ "email": "usuario@example.com", "password": "password123", "name": "Juan Pérez" }'curl -X POST http://localhost:3000/api/auth/login \ -H "Content-Type: application/json" \ -d '{ "email": "usuario@example.com", "password": "password123" }'curl -X GET http://localhost:3000/api/users/profile \ -H "Authorization: Bearer TU_ACCESS_TOKEN"curl -X PATCH http://localhost:3000/api/users/profile \ -H "Authorization: Bearer TU_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Juan Actualizado" }'Autenticación
POST /api/auth/register - Registrar nuevo usuario
POST /api/auth/login - Iniciar sesión
POST /api/auth/refresh - Renovar access token
POST /api/auth/logout - Cerrar sesión
Usuarios
GET /api/users/profile - Obtener perfil (requiere auth)
PATCH /api/users/profile - Actualizar perfil (requiere auth)
DELETE /api/users/profile - Eliminar cuenta (requiere auth)
Health
GET /health - Verificar estado del servidor
Registro exitoso:
{ "user": { "id": "uuid-here", "email": "usuario@example.com", "name": "Juan Pérez", "isActive": true, "createdAt": "2024-01-01T00:00:00.000Z" }, "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "eyJhbGciOiJIUzI1NiIs..."}Error de validación:
{ "error": "Error de validación", "details": [ { "field": "body.email", "message": "Email inválido" } ]}# Build para producciónnpm run build[build]builder = "NIXPACKS"
[deploy]startCommand = "npm start"healthcheckPath = "/health"healthcheckTimeout = 300Opción 1: Conecta tu repositorio de GitHub a Railway y configura las variables de entorno.
Opción 2: Usa el CLI de Railway:
# Instalar Railway CLInpm install -g @railway/cli
# Loginrailway login
# Inicializar proyectorailway init
# Añadir PostgreSQLrailway add postgresql
# Añadir variables de entornorailway variables set JWT_SECRET="your-production-secret"railway variables set JWT_REFRESH_SECRET="your-production-refresh-secret"
# Deployrailway up# Generar Prisma Clientrailway run npx prisma generate
# Ejecutar migracionesrailway run npx prisma migrate deploy¡Listo! Tu API estará disponible en la URL proporcionada por Railway.
Type Safety
TypeScript + Prisma + Zod para validación type-safe en todas las capas
Seguridad
Hashing de contraseñas, JWT con refresh tokens, validación de entradas
Testing
Tests unitarios e integración con Vitest, cobertura de código
Manejo de Errores
Middleware centralizado, mensajes descriptivos, logging apropiado
Organización
Estructura modular, separación de responsabilidades, código DRY
Production Ready
Variables de entorno, health checks, deployment automatizado
| Problema | Solución |
|---|---|
| Error de conexión a BD | Verifica que DATABASE_URL sea correcta y PostgreSQL esté corriendo |
| Token inválido | Asegúrate de usar el mismo JWT_SECRET en generación y verificación |
| Error de CORS | Configura el middleware cors() con los orígenes permitidos |
| Migración fallida | Elimina la BD y ejecuta npx prisma migrate reset |
| Tests fallan | Verifica que las variables de entorno estén configuradas en el entorno de test |
Ahora que tienes una API REST completa, puedes:
Añadir más features
Mejorar la infraestructura
Mejorar testing
Documentación API
El código completo de este tutorial está disponible en:
¿Preguntas? Abre un issue en GitHub o únete a nuestra comunidad de Discord.