Sesión 3 · chuleta
En el orden de las slides. Copia con el botón de cada bloque; si la slide dice «teclea», esas líneas van a mano.
📋 S3·01 · receta «Cambiar de rama» a s3-start (terminal)
Sección titulada «📋 S3·01 · receta «Cambiar de rama» a s3-start (terminal)»# Windows: todo en Git Bash (no PowerShell)git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s3 origin/s3-start # checkpoint: mi-s3-cp origin/s3-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed📋 S3·02 · instalar Express 5 y Zod (terminal)
Sección titulada «📋 S3·02 · instalar Express 5 y Zod (terminal)»npm install express zodnpm install -D @types/express📋 S3·03 · scripts dev y start (package.json, al principio de “scripts”)
Sección titulada «📋 S3·03 · scripts dev y start (package.json, al principio de “scripts”)»"dev": "tsx watch src/server.ts","start": "tsx src/server.ts",📋 S3·04 · server.ts completo
Sección titulada «📋 S3·04 · server.ts completo»import { app } from './app.js';
const PORT = Number(process.env.PORT ?? 3000);
app.listen(PORT, () => { console.log(`AcademIA API escuchando en http://localhost:${PORT}`);});📋 S3·05 · app.ts completo (de momento)
Sección titulada «📋 S3·05 · app.ts completo (de momento)»import express from 'express';import { coursesRouter } from './routes/courses.routes.js';
// La app se exporta sin arrancar: server.ts hace listen() y los tests// (sesión 6) la usan directamente con Supertest.export const app = express();
app.use(express.json());
app.use('/api/v1/courses', coursesRouter);📋 S3·06 · courses.repository.ts completo (de momento)
Sección titulada «📋 S3·06 · courses.repository.ts completo (de momento)»// src/repositories/courses.repository.ts (de momento, solo findMany)import { prisma } from '../db.js';
export const coursesRepository = { findMany: (params: { skip: number; take: number; categoryId?: number; }) => prisma.course.findMany({ where: { categoryId: params.categoryId, published: true }, skip: params.skip, take: params.take, // sin orden explícito, la paginación no es determinista orderBy: { id: 'asc' }, include: { category: true }, }),};📋 S3·07 · course.schema.ts (de momento, solo el del listado)
Sección titulada «📋 S3·07 · course.schema.ts (de momento, solo el del listado)»// src/schemas/course.schema.ts (de momento, solo el del listado)import { z } from 'zod';
export const listCoursesQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(50).default(10), categoryId: z.coerce.number().int().positive().optional(),});
export type ListCoursesQuery = z.infer<typeof listCoursesQuerySchema>;📋 S3·08 · courses.service.ts completo (de momento)
Sección titulada «📋 S3·08 · courses.service.ts completo (de momento)»import { coursesRepository } from '../repositories/courses.repository.js';import type { ListCoursesQuery } from '../schemas/course.schema.js';
export const coursesService = { listCourses: ({ page, pageSize, categoryId }: ListCoursesQuery) => coursesRepository.findMany({ skip: (page - 1) * pageSize, take: pageSize, categoryId, }),};📋 S3·09 · courses.controller.ts (de momento, solo list)
Sección titulada «📋 S3·09 · courses.controller.ts (de momento, solo list)»import type { Request, Response } from 'express';import { coursesService } from '../services/courses.service.js';import { listCoursesQuerySchema } from '../schemas/course.schema.js';
// Express 5: si un handler async lanza (o su promesa se rechaza), el error// llega solo al errorHandler. No hace falta try/catch ni asyncHandler.export const coursesController = { list: async (req: Request, res: Response) => { const query = listCoursesQuerySchema.parse(req.query); // ZodError → 400 res.status(200).json(await coursesService.listCourses(query)); },};📋 S3·10 · courses.routes.ts completo (de momento)
Sección titulada «📋 S3·10 · courses.routes.ts completo (de momento)»import { Router } from 'express';import { coursesController } from '../controllers/courses.controller.js';
export const coursesRouter = Router();
coursesRouter.get('/', coursesController.list);📋 S3·11 · chuleta de curl (terminal)
Sección titulada «📋 S3·11 · chuleta de curl (terminal)»export API=http://localhost:3000/api/v1 # guarda la URL; $API la usacurl -i "$API/courses?page=abc" # -i: status y cabecerascurl -X POST "$API/courses/4/enrollments" \ -H "Content-Type: application/json" \ -d '{"userId":1}' # -X método, -H cabecera, -d bodycurl -s -o /dev/null -w "%{http_code}\n" "$API/courses" # solo el códigocurl -s -D - -o /dev/null "$API/courses" # solo las cabeceras📋 S3·12 · otro puerto y la variable API (terminal)
Sección titulada «📋 S3·12 · otro puerto y la variable API (terminal)»PORT=3001 npm run dev # solo si el 3000 está ocupadoexport API=http://localhost:3001/api/v1 # export: si no, los checks no la vencurl "$API/courses" # todos los curl, con $APInpx tsx checks/s3.ts # lee API (por defecto, el 3000)📋 S3·13 · arrancar la API y probar el listado (terminal)
Sección titulada «📋 S3·13 · arrancar la API y probar el listado (terminal)»npm run dev # terminal 1: déjala abierta# terminal 2 (Windows: Git Bash)export API=http://localhost:3000/api/v1 # tu puerto, si no es el 3000curl "$API/courses?page=1&pageSize=5"curl -i "$API/courses?page=abc" # → 500📋 S3·14 · createCourseSchema (añade en course.schema.ts)
Sección titulada «📋 S3·14 · createCourseSchema (añade en course.schema.ts)»// añade en src/schemas/course.schema.ts (el import de z ya está)export const createCourseSchema = z.object({ title: z.string().min(3).max(120), description: z.string().max(2000).optional(), price: z.number().positive(), level: z.enum(['BEGINNER', 'INTERMEDIATE', 'ADVANCED']).optional(), published: z.boolean().optional(), maxSeats: z.number().int().positive().optional(), categoryId: z.number().int().positive().optional(), // ⚠ Provisional: en la sesión 4 se toma de req.user instructorId: z.number().int().positive(),});📋 S3·15 · idParamSchema y CreateCourseInput (añade en course.schema.ts)
Sección titulada «📋 S3·15 · idParamSchema y CreateCourseInput (añade en course.schema.ts)»// añade en src/schemas/course.schema.tsexport const idParamSchema = z.object({ id: z.coerce.number().int().positive(),});export type CreateCourseInput = z.infer<typeof createCourseSchema>;📋 S3·16 · validate.ts completo
Sección titulada «📋 S3·16 · validate.ts completo»import type { NextFunction, Request, Response } from 'express';import { z } from 'zod';
// Valida y sustituye req.body. (En Express 5 req.query es de solo lectura:// query y params se validan con schema.parse() dentro del controlador.)export const validateBody = (schema: z.ZodType) => (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.body); if (!result.success) { res.status(400).json({ error: 'Validation failed', details: z.flattenError(result.error), }); return; } req.body = result.data; next(); };📋 S3·17 · mover errors.ts a errors/app-error.ts (terminal)
Sección titulada «📋 S3·17 · mover errors.ts a errors/app-error.ts (terminal)»mkdir src/errorsgit mv src/errors.ts src/errors/app-error.ts📋 S3·18 · app-error.ts completo
Sección titulada «📋 S3·18 · app-error.ts completo»// src/errors/app-error.ts (antes src/errors.ts)// Errores de dominio con su código HTTP. El errorHandler los traduce a respuestas.export class AppError extends Error { constructor(public readonly statusCode: number, message: string) { super(message); this.name = new.target.name; }}
export class NotFoundError extends AppError { // CAMBIA: antes (message = 'Not found'); ahora recibe el recurso constructor(resource: string) { super(404, `${resource} not found`); }}
export class ConflictError extends AppError { constructor(message: string) { super(409, message); }}
// UnauthorizedError y ForbiddenError: se usan en la sesión 4export class UnauthorizedError extends AppError { constructor(message = 'Unauthorized') { super(401, message); }}
export class ForbiddenError extends AppError { constructor(message = 'Forbidden') { super(403, message); }}📋 S3·19 · error-handler.ts completo
Sección titulada «📋 S3·19 · error-handler.ts completo»import type { NextFunction, Request, Response } from 'express';import { z } from 'zod';import { Prisma } from '../../generated/prisma/client.js'; // no de '@prisma/client'import { AppError } from '../errors/app-error.js';
// Middleware de error: Express lo reconoce por tener 4 parámetros.// Siempre el último app.use().export function errorHandler( err: unknown, req: Request, res: Response, _next: NextFunction,) { if (err instanceof AppError) { return res.status(err.statusCode).json({ error: err.message }); } if (err instanceof z.ZodError) { return res.status(400).json({ error: 'Validation failed', details: z.flattenError(err), }); } // JSON mal formado en el body (lo lanza express.json()) if ((err as { type?: string }).type === 'entity.parse.failed') { return res.status(400).json({ error: 'Malformed JSON' }); } if (err instanceof Prisma.PrismaClientKnownRequestError) { switch (err.code) { case 'P2002': return res.status(409).json({ error: 'Resource already exists' }); case 'P2025': return res.status(404).json({ error: 'Resource not found' }); // FK: borrar algo que otros registros referencian, o crear con un id relacionado que no existe case 'P2003': return res.status(409).json({ error: 'Foreign key constraint failed (related resource missing or still referenced)' }); } } console.error(err); // en producción: un logger de verdad return res.status(500).json({ error: 'Internal server error' });}📋 S3·20 · enchufar el errorHandler (añade en app.ts)
Sección titulada «📋 S3·20 · enchufar el errorHandler (añade en app.ts)»// src/app.ts: el import arriba, con los demás…import { errorHandler } from './middlewares/error-handler.js';// …y al final del fichero, después de todas las rutasapp.use(errorHandler); // siempre el último📋 S3·21 · ?page=abc y ?pageSize=500 → 400 (terminal)
Sección titulada «📋 S3·21 · ?page=abc y ?pageSize=500 → 400 (terminal)»curl -i "$API/courses?page=abc" # → 400curl -i "$API/courses?pageSize=500" # → 400📋 S3·22 · traer solo lo nuevo del punto de control (terminal)
Sección titulada «📋 S3·22 · traer solo lo nuevo del punto de control (terminal)»git fetch origingit checkout origin/s3-checkpoint -- checks src/app.ts src/routes src/controllers/enrollments.controller.ts src/schemas/enrollment.schema.ts📋 S3·23 · receta «Cambiar de rama» al punto de control (terminal)
Sección titulada «📋 S3·23 · receta «Cambiar de rama» al punto de control (terminal)»git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s3-cp origin/s3-checkpoint # mi-s3 ya existe: otro nombrenpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed📋 S3·24 · comprobación de la práctica (terminal)
Sección titulada «📋 S3·24 · comprobación de la práctica (terminal)»npx tsx checks/s3.ts📋 S3·25 · código de la IA para auditar (s3-repo-servicio.ia.ts, solo para leer)
Sección titulada «📋 S3·25 · código de la IA para auditar (s3-repo-servicio.ia.ts, solo para leer)»// ─────────────────────────────────────────────────────────────────────────// Generado por un asistente de IA · Sesión 3// Prompt: "Genera el repositorio y el servicio de courses para mi API Express// con Prisma: listar paginado, obtener por id, crear, editar, borrar"// Tu tarea: encuentra lo que falta o está mal. Pista: hay al menos 5 cosas.// ─────────────────────────────────────────────────────────────────────────import type { Request, Response } from 'express';import { prisma } from '../db.js';
export const coursesRepository = { findMany: (page: number, pageSize: number) => prisma.course.findMany({ skip: (page - 1) * pageSize, take: pageSize }), findById: (id: number) => prisma.course.findUniqueOrThrow({ where: { id } }), create: (data: any) => prisma.course.create({ data }), update: (id: number, data: any) => prisma.course.update({ where: { id }, data }), delete: (id: number) => prisma.course.delete({ where: { id } }),};
export const coursesController = { list: async (req: Request, res: Response) => { const page = Number(req.query.page) || 1; const pageSize = Number(req.query.pageSize) || 10; res.json(await coursesRepository.findMany(page, pageSize)); }, get: async (req: Request, res: Response) => { res.json(await coursesRepository.findById(Number(req.params.id))); }, create: async (req: Request, res: Response) => { res.json(await coursesRepository.create(req.body)); }, remove: async (req: Request, res: Response) => { await coursesRepository.delete(Number(req.params.id)); res.json({ ok: true }); },};