Sesión 3 — Diseño de APIs REST con Express
Bloque: ORMs, APIs REST y Seguridad
Fecha: Jueves 26 de noviembre · 15:30–19:30 (4h)
Stack: Node.js ≥ 24, TypeScript, Express 5, Prisma 7, Zod 4
Continúa de: Sesión 2 — Relaciones, queries y transacciones
Repo: academia-api · partimos de s3-start (= final de la sesión 2) · punto de control s3-checkpoint (antes de la práctica) · terminamos en s3-end (= s4-start)
Objetivos de la sesión
Sección titulada «Objetivos de la sesión»Al terminar, el alumno debe ser capaz de:
- Diseñar endpoints REST: recursos en la URL, el verbo HTTP adecuado y el código de estado correcto.
- Estructurar una API en capas: controlador → servicio → repositorio.
- Validar body, query y params con Zod (y esquivar la trampa de
req.queryen Express 5). - Centralizar el manejo de errores en un único middleware, incluidos los errores de Prisma.
- Construir el CRUD REST de
Coursesobre esas capas. - Auditar código de API generado por IA y corregirlo.
Hoy pasamos de “scripts que ejecutan queries” a “una API real que otros pueden consumir”. Todo lo que hicimos con Prisma en las dos sesiones anteriores se queda igual — hoy le ponemos una capa HTTP encima.
Cómo trabajamos hoy
Sección titulada «Cómo trabajamos hoy»Trabajáis en vuestro clon de academia-api, en vuestra propia rama. Sigue en tu rama. Si los checks del inicio de la sesión fallan o vas tarde, usa la receta “Cambiar de rama”. Checks del inicio de hoy: npm run seed → Seed OK: 3 cursos, 3 inscripciones y npm run typecheck sin errores. Hay dos puntos de rescate.
Bloques para copiar: web del curso → Sesión 3 → Chuleta. Cada bloque tiene un ID (📋 S3·07) que verás en la slide. Lo que enseña la idea se teclea a mano (lo marcamos con ✍️); el resto (imports, ficheros enteros, comandos, curl) se copia de la web.
¿No pasas los checks del inicio? Al principio de la clase, la receta única “Cambiar de rama” (la misma en todas las sesiones). En vuestro clon las ramas del curso solo existen como origin/…, por eso el fetch:
# 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 seedSi vienes de tu propia rama, migrate dev pedirá reset: es lo esperado (tus migraciones tienen otro timestamp que las del repo); después, npm run seed. Traer un solo fichero: git checkout origin/s3-checkpoint -- ruta/del/fichero. El seed es npm run seed (existe desde la sesión 1). Windows: todo en Git Bash (no PowerShell); ahí funcionan tal cual el &&, los curl y el export de la chuleta de §4.5.
Antes de la práctica, el punto de control. s3-checkpoint es exactamente lo que vamos a escribir juntos hasta las 18:05, más dos cosas que os damos hechas: los endpoints de inscripción y el script de comprobación checks/s3.ts. ¿Tienes la primera mitad? El criterio: con la API arrancada, curl -i "$API/courses?page=abc" responde 400. Si es así, sigue en tu rama y trae solo lo nuevo; si no, la receta al punto de control. Los comandos de las dos vías están en §7 (📋 S3·22 y 📋 S3·23).
1. Arranque: de scripts a API
Sección titulada «1. Arranque: de scripts a API»2. REST en 25 minutos
Sección titulada «2. REST en 25 minutos»REST no es un protocolo ni una librería — es un estilo arquitectónico con unas reglas. Las que nos importan hoy:
2.1 Recursos, no acciones, en la URL
Sección titulada «2.1 Recursos, no acciones, en la URL»Las URLs representan sustantivos (recursos), no verbos. El verbo va en el método HTTP.
| ❌ Mal | ✅ Bien |
|---|---|
GET /getCourses |
GET /courses |
POST /createCourse |
POST /courses |
POST /deleteCourse?id=3 |
DELETE /courses/3 |
GET /courses/3/getEnrollments |
GET /courses/3/enrollments |
2.2 Verbos HTTP
Sección titulada «2.2 Verbos HTTP»| Verbo | Uso | ¿Body? |
|---|---|---|
GET |
Leer un recurso o colección | No |
POST |
Crear un recurso nuevo | Sí |
PATCH |
Actualizar parcialmente | Sí |
DELETE |
Eliminar un recurso | No (normalmente) |
Idempotente = repetir la petición deja el servidor en el mismo estado (no da la misma respuesta: el segundo DELETE /courses/7 devuelve 404, y está bien).
2.3 Códigos de estado
Sección titulada «2.3 Códigos de estado»| Código | Significado | Cuándo usarlo hoy |
|---|---|---|
200 OK |
Éxito genérico | GET y PATCH correctos |
201 Created |
Recurso creado | POST correcto (con el recurso en el body) |
204 No Content |
Éxito sin body | DELETE correcto |
400 Bad Request |
Input inválido | Falla la validación (Zod), JSON mal formado |
404 Not Found |
El recurso no existe | id inexistente |
409 Conflict |
Choca con el estado actual | @@unique violado, borrar un curso con inscripciones |
500 Internal Server Error |
Error no controlado | Un bug. Nunca debería ser la respuesta a un input malo |
401 y 403 llegan en la sesión 4 (autenticación y autorización).
2.4 Menciones (no entran hoy)
Sección titulada «2.4 Menciones (no entran hoy)»- Statelessness: cada request lleva toda su información; el servidor no “recuerda” quién hizo login. Lo retomamos en la sesión 4 con JWT.
- Versionado: usamos
/api/v1/...para que un cambio futuro no rompa a los clientes que ya existen. PUTreemplaza el recurso entero (hoy soloPATCH);422es una alternativa a400que algunos equipos usan.
2.5 Diseño de endpoints de AcademIA
Sección titulada «2.5 Diseño de endpoints de AcademIA»Con el modelo de datos de las sesiones 1-2, lo diseñamos juntos en la pizarra. Así queda el reparto de hoy:
GET /api/v1/courses → juntos (code-along): cursos publicados, filtro categoryId, page y pageSize ≤ 50GET /api/v1/courses/:id → prácticaPOST /api/v1/courses → prácticaPATCH /api/v1/courses/:id → prácticaDELETE /api/v1/courses/:id → prácticaPOST /api/v1/courses/:id/enrollments → ya hecho (punto de control): usa enrollStudent de la sesión 2GET /api/v1/users/:id/enrollments → ya hecho (punto de control)Una regla de nesting: máximo un nivel (/courses/:id/enrollments, no /courses/:id/enrollments/:id/course).
3. Arquitectura en capas
Sección titulada «3. Arquitectura en capas»Si todo el código vive dentro del handler de Express, se vuelve imposible de testear y de mantener. Separamos en 3 capas:
Request → Router → Controller → Service → Repository → Prisma → DB| Capa | Responsabilidad | NO debe hacer |
|---|---|---|
| Router | Mapear URL + verbo → controlador (y validar el body) | Lógica de negocio |
| Controller | Leer req, llamar al servicio, construir res |
Saber nada de Prisma ni reglas de negocio |
| Service | Lógica de negocio (404 si no existe, reglas de dominio, transacciones) | Saber nada de req/res de Express |
| Repository | Acceso a datos (envuelve las llamadas a Prisma) | Lógica de negocio |
¿Por qué?
- Testabilidad: en la sesión de testing (14 dic) testearemos el service sin levantar Express.
- Un sitio para las reglas: en la sesión 4, “solo el dueño puede editar su curso” va en el service. Si el controlador habla directamente con el repositorio, no hay dónde ponerlo.
- Cambio de capa de datos: si mañana cambiáis Prisma por otra cosa, solo tocáis el repository.
Estructura al final de hoy (lo nuevo respecto a s3-start):
checks/ s3.ts ← comprobación de la práctica (llega con el punto de control)src/ routes/ courses.routes.ts, users.routes.ts controllers/ courses.controller.ts, enrollments.controller.ts services/ courses.service.ts (enrollments.service.ts ya existe: sesión 2) repositories/ courses.repository.ts middlewares/ error-handler.ts, validate.ts schemas/ course.schema.ts, enrollment.schema.ts errors/ app-error.ts ← antes src/errors.ts (sesión 2) app.ts server.ts4. Code-along: Express 5 + GET /courses
Sección titulada «4. Code-along: Express 5 + GET /courses»A partir de aquí, code-along: yo voy en pantalla; vosotros, en vuestra rama. Cada fichero se copia de la web (📋 con su ID, en la chuleta de la sesión 3), lo leemos juntos y tecleáis a mano solo las líneas ✍️, que son las que enseñan la idea: hoy, en esta parte, el listCoursesQuerySchema y el list del controlador.
4.1 Instalación y scripts
Sección titulada «4.1 Instalación y scripts»npm install express zodnpm install -D @types/expressHoy npm install express instala Express 5 (5.3). Comprobadlo con npm ls express. En package.json, pega estas dos líneas al principio de "scripts", justo después de la llave { (llevan su coma). "seed" y "typecheck" ya los tienes desde la sesión 1: no los dupliques (npm no se queja de una clave repetida y luego no sabes cuál vale).
"dev": "tsx watch src/server.ts","start": "tsx src/server.ts",4.2 src/server.ts — arrancar el servidor
Sección titulada «4.2 src/server.ts — arrancar el servidor»// src/server.tsimport { app } from './app.js';
const PORT = Number(process.env.PORT ?? 3000);
app.listen(PORT, () => { console.log(`AcademIA API escuchando en http://localhost:${PORT}`);});4.3 src/app.ts — la app, sin listen()
Sección titulada «4.3 src/app.ts — la app, sin listen()»De momento, solo con el router de cursos (el errorHandler llega en §6 y el usersRouter, con el punto de control):
// src/app.tsimport 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);El proyecto es ESM ("type": "module"): los imports relativos llevan .js aunque el fichero sea .ts.
4.4 Repositorio, schema, servicio, controlador y ruta
Sección titulada «4.4 Repositorio, schema, servicio, controlador y ruta»// 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 }, }),};El query string hay que validarlo y convertirlo (llega como texto). Escribimos ya el primer schema de Zod; en §5 vemos cómo funciona. ✍️ A mano: el z.object con los tres z.coerce (el import y el tipo, cópialos).
// 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>;El servicio traduce page/pageSize a skip/take, y no conoce req ni res:
// src/services/courses.service.tsimport { 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, }),};Los tipos se importan con import type
Sección titulada «Los tipos se importan con import type»El tsconfig.json del proyecto lleva "verbatimModuleSyntax": true y el proyecto es ESM: cada import llega tal cual al JavaScript que se ejecuta. Request y Response (y ListCoursesQuery) solo existen para TypeScript; en runtime Express no exporta nada con ese nombre. Por eso:
// ✗ El servidor no arranca:import { Request, Response } from 'express';// SyntaxError: The requested module 'express' does not provide an export named 'Request'// (tsc: TS1484 'Request' is a type and must be imported using a type-only import)
// ✓ Tipos con import type; valores (Router, funciones, objetos) con import normalimport type { Request, Response } from 'express';import { Router } from 'express';Regla: lo que solo usas como tipo (: Request, ListCoursesQuery) va con import type. Si olvidas el import del todo, TypeScript usa el Request global de fetch y verás Property 'query' does not exist on type 'Request'.
✍️ A mano: el list (las dos líneas de dentro: parse(req.query) y la respuesta). Los imports y el router, cópialos.
// src/controllers/courses.controller.tsimport 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)); },};// src/routes/courses.routes.tsimport { Router } from 'express';import { coursesController } from '../controllers/courses.controller.js';
export const coursesRouter = Router();
coursesRouter.get('/', coursesController.list);4.5 Chuleta: curl y la terminal
Sección titulada «4.5 Chuleta: curl y la terminal»Es la referencia de curl para las sesiones 3, 4 y 5. Vale tal cual en macOS, Linux y Git Bash. ¿PowerShell? No: abre Git Bash.
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-ssin barra de progreso ·-omanda el body a un fichero (/dev/null: tirarlo) ·-wimprime algo al acabar ·-D -vuelca las cabeceras a la pantalla.- El POST es solo de ejemplo: la ruta de inscripciones llega con el punto de control (§7), y los ids de curso cambian con cada seed: míralos en
GET /courses(el4del ejemplo es orientativo).
Otro puerto, la variable API y los checks:
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)PORT=3001delante del comando vale solo para ese comando: el servidor leeprocess.env.PORT(src/server.ts).- Sin
export,API=…es una variable del shell: tuscurlla ven, peronpx tsxno, y el check diceNo hay API en http://localhost:3000/api/v1…aunque tu API esté arrancada en otro puerto. - Cada terminal nueva empieza sin
API: repite elexporten la terminal donde lanzas loscurly los checks.
4.6 ¡Funciona!
Sección titulada «4.6 ¡Funciona!»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" # → 500Dos cosas de la respuesta: solo salen cursos con published: true (los borradores no se exponen), y "price": "49.99" llega como string (Decimal se serializa así para no perder precisión).
Ahora probad ?page=abc: 500. Zod ha detectado el error, pero nadie lo traduce a una respuesta. Lo arreglamos en §6.
5. Validación con Zod
Sección titulada «5. Validación con Zod»Si alguien manda price: "gratis" al crear un curso, o ?page=abc al listar, Prisma falla con un error críptico y el cliente recibe un 500. Hay que validar antes de llegar a la capa de datos. Usamos Zod 4 (4.6).
5.1 El schema de creación y el de params
Sección titulada «5.1 El schema de creación y el de params»Completamos src/schemas/course.schema.ts. ✍️ A mano: title, price e instructorId; el resto de campos, cópialos.
// 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(),});// 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>;z.infergenera el tipo de TypeScript desde el schema — una sola fuente de verdad para validación en runtime y tipos en compile-time.- Query y params llegan siempre como strings (
"2","abc").z.coerce.number()los convierte; si no sale un número válido ("abc"→NaN), falla la validación. instructorIden el body es provisional.Courselo exige y todavía no hay login. En la sesión 4 desaparece del schema y se toma dereq.user.publishedes opcional (por defectofalse): un curso creado sinpublished: truees un borrador y no aparece en el listado. Es a propósito.
El schema de actualización (updateCourseSchema) lo escribís vosotros en la práctica.
5.2 Middleware de validación del body
Sección titulada «5.2 Middleware de validación del body»✍️ A mano: el middleware entero (es el patrón de todo middleware de Express: req, res, next); de 📋 S3·16, solo los imports.
// src/middlewares/validate.tsimport 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(); };Se usa en la ruta, delante del controlador: validateBody(createCourseSchema). Lo enchufaréis en la práctica.
Ojo con la IA (entrenada sobre todo con Zod 3): os sugerirá ZodSchema y err.flatten(). En Zod 4 son z.ZodType y z.flattenError(err).
5.3 ⚠ La trampa de Express 5: req.query es de solo lectura
Sección titulada «5.3 ⚠ La trampa de Express 5: req.query es de solo lectura»El middleware valida y sustituye req.body. Lo natural sería un validateQuery gemelo que haga req.query = result.data. En Express 5 no funciona: req.query es un getter que se recalcula desde la URL y no se puede reasignar. Así que el reparto queda:
- Body → middleware
validateBodyen la ruta (reemplazareq.body, que sí se puede). - Query y params →
schema.parse()dentro del controlador, que devuelve los datos ya convertidos. Es lo que ya hicimos en ellist:
const query = listCoursesQuerySchema.parse(req.query); // ZodError → 400const { id } = idParamSchema.parse(req.params);parse lanza un ZodError si falla; no lo capturamos aquí: el middleware de errores (§6) lo convertirá en 400.
6. Manejo de errores centralizado
Sección titulada «6. Manejo de errores centralizado»6.1 El problema de los try/catch repetidos
Sección titulada «6.1 El problema de los try/catch repetidos»Sin un patrón centralizado, cada controlador termina así:
// ❌ Repetitivo y fácil de olvidar en algún endpointexport const create = async (req: Request, res: Response) => { try { const course = await coursesService.createCourse(req.body); res.status(201).json(course); } catch (err) { res.status(500).json({ error: 'Something went wrong' }); }};6.2 Express 5: los errores async llegan solos al errorHandler
Sección titulada «6.2 Express 5: los errores async llegan solos al errorHandler»En Express 5, si un handler async lanza (o su promesa se rechaza), Express llama a next(err) por vosotros. No hace falta try/catch ni ningún wrapper. Por eso el list que escribimos no tiene ni un try/catch (es lo que dice el comentario que dejamos encima de coursesController): el ZodError de ?page=abc ya está llegando a Express… y Express, sin nadie que lo traduzca, responde 500.
Si estás en Express 4: ahí un async que lanza no llega a next: la promesa rechazada queda sin gestionar (la petición se queda colgada o el proceso se cae). Se arreglaba envolviendo cada handler en un asyncHandler que hacía .catch(next). Lo veréis en muchos tutoriales y en código generado por IA. Con Express 5, sobra.
6.3 Clases de error de dominio
Sección titulada «6.3 Clases de error de dominio»Movemos src/errors.ts (sesión 2) a src/errors/app-error.ts y con un cambio: NotFoundError pasa a recibir el recurso (new NotFoundError('Course') → "Course not found"). Si solo mueves el fichero y no cambias el constructor, la respuesta diría {"error":"Course"}; los checks no lo detectan porque solo miran el status. Primero, muévelo (git mv necesita que la carpeta exista):
mkdir src/errorsgit mv src/errors.ts src/errors/app-error.tsY luego sustituye su contenido entero por este (es el tuyo con el cambio de NotFoundError):
// 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); }}✍️ A mano, en src/services/enrollments.service.ts: el import pasa a '../errors/app-error.js' y new NotFoundError('Course not found') pasa a new NotFoundError('Course') (en los dos sitios donde aparece). Si no, Cannot find module '../errors.js'.
6.4 Middleware final de errores
Sección titulada «6.4 Middleware final de errores»✍️ A mano: los if (AppError, ZodError, JSON mal formado), el switch de Prisma y el 500 final. Los imports y la firma de la función, cópialos de 📋 S3·19.
// src/middlewares/error-handler.tsimport 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' });}Y en src/app.ts, como último app.use(), después de todas las rutas:
// 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| Error | Quién lo lanza | Respuesta |
|---|---|---|
AppError (NotFoundError, ConflictError…) |
nuestro service | su statusCode |
ZodError |
schema.parse() en el controlador |
400 |
entity.parse.failed |
express.json() con un body que no es JSON válido |
400 (sin esto, 500) |
P2002 |
Prisma: violación de @unique / @@unique (inscripción repetida) |
409 |
P2025 |
Prisma: el registro no existe (update/delete/…OrThrow) |
404 |
P2003 |
Prisma: violación de clave foránea (borrar un curso con inscripciones) | 409 |
Prismase importa del cliente generado (generated/prisma/client.js), no de@prisma/client(Prisma 7): con@prisma/clientel servidor no arranca (does not provide an export named 'Prisma'), y es lo que os sugerirán el autocompletado y la IA. Con el driver adapter@prisma/adapter-pgestos errores siguen llegando comoPrismaClientKnownRequestErrorcon su código (comprobado).P2025es la red de seguridad: el service comprobará que el curso existe antes deupdate/delete, pero si alguien usafindUniqueOrThrowo hay una carrera entre la comprobación y el borrado, el cliente recibe404y no500.P2003cierra un agujero real: sin él, borrar un curso con inscripciones da500. ¿Borrarlas en cascada o prohibirlo? Es una decisión de negocio; hoy elegimos prohibirlo con un409.
Probad otra vez (sin el app.use(errorHandler) seguiréis viendo 500):
curl -i "$API/courses?page=abc" # → 400curl -i "$API/courses?pageSize=500" # → 400El primero trae en details el campo que ha fallado.
7. Punto de control + inscripciones ya hechas
Sección titulada «7. Punto de control + inscripciones ya hechas»¿Tienes la primera mitad? Con la API arrancada, curl -i "$API/courses?page=abc" → 400. Si es así, sigue en tu rama y trae solo lo nuevo (pisa tu app.ts y tus rutas con los mismos más las rutas de inscripción montadas):
git fetch origingit checkout origin/s3-checkpoint -- checks src/app.ts src/routes src/controllers/enrollments.controller.ts src/schemas/enrollment.schema.tsSi no (da 500, la API no arranca) o vas tarde, guarda lo tuyo y pasa al punto de control con la receta:
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 seedSi vienes de tu propia rama, migrate dev pedirá reset: es lo esperado (tus migraciones tienen otro timestamp que las del repo); después, npm run seed. Por cualquiera de las dos vías, al final, siempre: npm run seed (deja la base como la esperan los checks). Lo tuyo queda guardado en tu rama, en el commit.
El punto de control trae lo que hemos escrito juntos y, además, hecho para vosotros:
POST /api/v1/courses/:id/enrollmentscon{ "userId": 3 }en el body →201; repetida →409. ReutilizaenrollStudentde la sesión 2.GET /api/v1/users/:id/enrollments→200con las inscripciones del usuario y el título de cada curso.
Miradlos, no los escribáis: son un buen ejemplo de las capas y de la validación que acabamos de ver.
coursesRouter.post('/:id/enrollments', validateBody(createEnrollmentSchema), enrollmentsController.create);
// src/controllers/enrollments.controller.tscreate: async (req: Request, res: Response) => { const { id: courseId } = idParamSchema.parse(req.params); res.status(201).json(await enrollStudent(req.body.userId, courseId));},// GET /api/v1/users/:id/enrollments (en users.routes.ts, montado en app.ts)listForUser: async (req: Request, res: Response) => { const { id: userId } = idParamSchema.parse(req.params); const enrollments = await prisma.enrollment.findMany({ where: { userId }, include: { course: { select: { id: true, title: true } } }, orderBy: { enrolledAt: 'desc' }, }); res.status(200).json(enrollments);},userId en el body es provisional, como instructorId: en la sesión 4 sale de req.user. Y fijaos en listForUser: el controlador habla con Prisma directamente, saltándose las capas. ¿Dónde debería ir esa query? Lo vemos en la puesta en común de la parte A.
8. Práctica: auditar el código de la IA + CRUD de Course
Sección titulada «8. Práctica: auditar el código de la IA + CRUD de Course»Material: s3-repo-servicio.ia.ts (código “generado por IA” para el repositorio y el controlador de cursos) está en la web: 📋 S3·25, más abajo. checks/s3.ts ya lo tenéis: viene en el punto de control.
Antes de empezar, con la API arrancada (npm run dev), en otra terminal (si tu API no está en el 3000, usa la variable API: ver §4.5):
npx tsx checks/s3.tsDebe salir 8 ok, 9 fallos: los fallos son vuestro trabajo de hoy.
Parte A — Auditoría (10 min, por parejas) + puesta en común (5 min). Leed s3-repo-servicio.ia.ts como si fuera la PR de un compañero. Hay al menos 5 fallos. Para cada uno, apuntad qué verá el cliente (¿qué código de estado?, ¿qué datos?). No lo ejecutéis. En la puesta en común, también: listForUser, ¿qué capa se salta?
// ─────────────────────────────────────────────────────────────────────────// 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 }); },};Parte B — Corregir e integrar (25 min). No peguéis el fichero de la IA tal cual: llevad lo que sirva a vuestras capas, corrigiendo los fallos, hasta tener funcionando:
GET /api/v1/courses/:id→200, o404si no existe (no500);/courses/abc→400.POST /api/v1/courses→201, validado convalidateBody(createCourseSchema).DELETE /api/v1/courses/:id→204; con inscripciones →409; otra vez →404.
Seguid los comentarios // Práctica: del punto de control: están en el repositorio, el servicio, el controlador y las rutas (si te quedaste en tu rama, solo los tienes en las rutas; guíate por la tabla de firmas).
Parte C — Desde cero (15 min). PATCH /api/v1/courses/:id → 200, o 404 si no existe. Validado con updateCourseSchema: todos los campos opcionales y sin instructorId. No viene en el punto de control (solo hay un comentario // Práctica: en course.schema.ts): escríbelo tú, junto con su tipo UpdateCourseInput (pista: .omit() y .partial() de Zod).
Nombres y firmas obligatorios (las sesiones 4 y 6 los dan por hechos):
| Fichero | Exporta |
|---|---|
schemas/course.schema.ts |
createCourseSchema, updateCourseSchema, listCoursesQuerySchema, idParamSchema y los tipos CreateCourseInput, UpdateCourseInput, ListCoursesQuery |
repositories/courses.repository.ts |
coursesRepository.findMany({ skip, take, categoryId? }), findById(id), create(data: CreateCourseInput), update(id, data: UpdateCourseInput), delete(id) |
services/courses.service.ts |
coursesService.listCourses(query: ListCoursesQuery), getCourse(id) (lanza NotFoundError('Course')), createCourse(data), updateCourse(id, data), deleteCourse(id) |
controllers/courses.controller.ts |
coursesController.{ list, get, create, update, remove } |
Parte D — Comprobación (10 min). Otra vez npx tsx checks/s3.ts.
Criterio de “hecho”: 17 ok, 0 fallos. Comprueba, entre otros:
?page=abcy?pageSize=500→400;GET /courses/abc→400.POSTcon precio negativo →400; con JSON mal formado →400; válido →201.GET /courses/99999→404(no500);PATCH→200/404.- Inscripción →
201y repetida →409;GET /users/:id/enrollments→200(se ponen en verde solas cuando funcionaPOST /courses). DELETEde un curso con inscripciones →409; sin ellas →204, y otra vez →404.
¿Te sobra tiempo? (opcional, en clase, no son deberes) Implementa GET /api/v1/courses/:id/enrollments, el que diseñamos y no hicimos.
9. Cierre y adelanto de la sesión 4
Sección titulada «9. Cierre y adelanto de la sesión 4»Resumen (lo contáis vosotros):
- Por qué las URLs son sustantivos y qué código devuelve cada caso (y por qué un
500ante un input malo es un bug). - Qué gana el proyecto al separar controller / service / repository.
- Por qué el body se valida en un middleware y query/params con
parseen el controlador (Express 5). - Cómo un error de Prisma (
P2002,P2025,P2003) termina siendo un409/404para el cliente. - Qué fallos tenía el código de la IA.
Si no has terminado: origin/s3-end (= origin/s4-start) es exactamente el final de hoy. Usa la receta “Cambiar de rama” con git switch -c fin-s3 origin/s3-end en lugar de la línea del switch. La sesión 4 empieza desde ahí.
Adelanto sesión 4 (30 nov): ahora mismo la API es completamente abierta — cualquiera puede crear, editar o borrar cualquier curso, y el cliente dice en el body quién es el instructor (instructorId) o el alumno (userId). El lunes añadimos autenticación (hash de contraseñas, JWT) y autorización por roles: esos dos campos salen del body y se toman de req.user, solo un INSTRUCTOR podrá crear cursos y solo el dueño editarlos.