Crea API RESTful con Node.js, Express y TypeScript
En este tutorial crearás una API administrativa de perfiles de usuario con Express, TypeScript y PostgreSQL. Abarca operaciones CRUD autenticadas, validación, persistencia en la base de datos y errores coherentes. Almacena datos de perfiles; tu proveedor de identidad se encarga del inicio de sesión y del almacenamiento de contraseñas.
Introducción
La API atiende a administradores de confianza con tokens de acceso firmados. Un token necesita el
alcance profiles:read para leer perfiles y profiles:write para crearlos, editarlos o eliminarlos.
Estos alcances autorizan el acceso a todos los perfiles; esta no es una API de autoservicio para
usuarios. El emisor de confianza asigna los alcances. Los cuerpos de las solicitudes no pueden elegir
una identidad ni un rol de autorización.
Requisitos del sistema
Usa Node.js 24, Yarn 4 y una base de datos local PostgreSQL 17. El ejemplo fija las versiones Express 5.2.1, TypeORM 1.1.1 y TypeScript 5.9.3. Express 5 reenvía las promesas rechazadas de los manejadores de rutas al middleware de errores.
También necesitas un proveedor de identidad que emita tokens de acceso JWT RS256 con emisor,
audiencia, sujeto, vencimiento y una declaración scope con valores separados por espacios.
Obtén su clave pública de verificación a través del canal de configuración de confianza de tu
proveedor.
Configura el entorno
mkdir stack-api
cd stack-api
yarn init -2
yarn add express@5.2.1 helmet@8.3.0 typeorm@1.1.1 pg@8.23.0 reflect-metadata@0.2.2 jose@6.2.12 zod@3.25.76
yarn add --dev typescript@5.9.3 @types/node@24.10.1 @types/express@5.0.6
Establece "type": "module" en package.json. Crea tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}
Usa una configuración de entorno exclusiva del servidor: DATABASE_URL, JWT_ISSUER, JWT_AUDIENCE
y JWT_PUBLIC_KEY (la clave pública PEM con saltos de línea reales).
PORT tiene el valor predeterminado 3000.
Establece SCHEMA_SYNC=development-only solo para una base de datos local desechable. Mantén las
credenciales de la base de datos fuera del control de versiones y nunca desactives la verificación
TLS para una base de datos remota.
Estructura del proyecto
src/
├── config/database.ts
├── controllers/userController.ts
├── middleware/errorHandler.ts
├── middleware/auth.ts
├── models/User.ts
├── routes/userRoutes.ts
├── app.ts
└── index.ts
Configura la conexión a la base de datos
Crea src/config/database.ts:
import 'reflect-metadata'
import { DataSource } from 'typeorm'
import { User } from '../models/User.js'
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required')
export const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
synchronize: process.env.SCHEMA_SYNC === 'development-only',
logging: false,
entities: [User],
})
Los especificadores de importación .js hacen referencia a los archivos
generados en este proyecto NodeNext independiente.
Compila con TypeScript: la eliminación de tipos de Node no transforma los decoradores de TypeORM.
Usa migraciones de TypeORM revisadas
para las bases de datos desplegadas.
Crea el modelo de usuario
Crea src/models/User.ts:
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column({ type: 'varchar', length: 100 })
name!: string
@Column({ type: 'varchar', length: 254, unique: true })
email!: string
@CreateDateColumn()
createdAt!: Date
}
No hay ninguna columna de contraseña. Crear un perfil no crea una cuenta de inicio de sesión ni concede acceso.
Middleware de manejo de errores
Crea src/middleware/errorHandler.ts. Genera un ID de solicitud nuevo para cada solicitud;
nunca confíes en el encabezado de ID de solicitud de un cliente como identificador de auditoría.
import { randomUUID } from 'node:crypto'
import type { ErrorRequestHandler, RequestHandler } from 'express'
import { QueryFailedError } from 'typeorm'
import { z, ZodError } from 'zod'
export class AppError extends Error {
constructor(public statusCode: number, message: string) {
super(message)
}
}
export const requestId: RequestHandler = (_req, res, next) => {
res.locals.requestId = randomUUID()
res.setHeader('X-Request-ID', res.locals.requestId)
res.setHeader('Cache-Control', 'no-store')
next()
}
export const errorHandler: ErrorRequestHandler = (error, _req, res, next) => {
if (res.headersSent) {
next(error)
return
}
let status = 500
let message = 'Request failed'
if (error instanceof AppError) {
status = error.statusCode
message = error.message
} else if (error instanceof ZodError) {
status = 400
message = 'Invalid request'
} else if (error instanceof QueryFailedError &&
z.object({ code: z.literal('23505') }).safeParse(error.driverError).success) {
status = 409
message = 'Email is already in use'
} else {
const parsed = z.object({
type: z.enum(['entity.parse.failed', 'entity.too.large']),
}).safeParse(error)
if (parsed.success) {
status = parsed.data.type === 'entity.too.large' ? 413 : 400
message = 'Invalid request body'
}
}
console.error(JSON.stringify({ event: 'request_failed', requestId: res.locals.requestId, status }))
res.status(status).json({ error: message, requestId: res.locals.requestId })
}
Crea src/middleware/auth.ts. La clave pública, el emisor, la audiencia y el algoritmo
configurados forman parte de la política del servidor; ningún encabezado de token ni parámetro de
solicitud puede reemplazarlos.
La verificación de JWT de jose
comprueba la firma y las declaraciones.
import type { RequestHandler } from 'express'
import { importSPKI, jwtVerify } from 'jose'
import { AppError } from './errorHandler.js'
const { JWT_PUBLIC_KEY, JWT_ISSUER, JWT_AUDIENCE } = process.env
if (!JWT_PUBLIC_KEY || !JWT_ISSUER || !JWT_AUDIENCE) {
throw new Error('JWT verification configuration is required')
}
const verificationKey = await importSPKI(JWT_PUBLIC_KEY, 'RS256')
export function authorize(scope: string): RequestHandler {
return async (req, _res, next) => {
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(req.headers.authorization ?? '')
if (!match) throw new AppError(401, 'Authentication required')
let payload
try {
const verified = await jwtVerify(match[1], verificationKey, {
algorithms: ['RS256'],
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
requiredClaims: ['sub', 'exp', 'iat'],
maxTokenAge: '15m',
})
payload = verified.payload
} catch {
throw new AppError(401, 'Invalid access token')
}
if (typeof payload.scope !== 'string' || !payload.scope.split(' ').includes(scope)) {
throw new AppError(403, 'Permission denied')
}
next()
}
}
Configura el servidor Express
Crea src/app.ts:
import express from 'express'
import helmet from 'helmet'
import { errorHandler, requestId } from './middleware/errorHandler.js'
import { userRoutes } from './routes/userRoutes.js'
export const app = express()
app.disable('x-powered-by')
app.use(requestId)
app.use(helmet())
app.use('/api/v1/users', userRoutes)
app.use((_req, res) => res.status(404).json({
error: 'Not found', requestId: res.locals.requestId,
}))
app.use(errorHandler)
Crea src/index.ts:
import { app } from './app.js'
import { AppDataSource } from './config/database.js'
async function main(): Promise<void> {
await AppDataSource.initialize()
const server = app.listen(Number(process.env.PORT ?? 3000), '127.0.0.1')
server.requestTimeout = 30_000
server.headersTimeout = 10_000
}
main().catch(() => {
console.error('API startup failed')
process.exitCode = 1
})
Para el despliegue, configura el servidor para que escuche detrás de un proxy inverso HTTPS. No expongas un endpoint HTTP de desarrollo ni añadas una configuración CORS permisiva para que funcione un cliente de navegador.
Controlador de usuarios
Crea src/controllers/userController.ts. Los esquemas estrictos rechazan los campos adicionales en vez de
aceptar silenciosamente id, password, role o marcas de tiempo.
import type { Request, Response } from 'express'
import { z } from 'zod'
import { AppDataSource } from '../config/database.js'
import { AppError } from '../middleware/errorHandler.js'
import { User } from '../models/User.js'
const profiles = AppDataSource.getRepository(User)
const profileSchema = z.object({
name: z.string().trim().min(1).max(100),
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
}).strict()
const patchSchema = profileSchema.partial().refine((value) => Object.keys(value).length > 0)
const idSchema = z.string().uuid()
export async function getUsers(req: Request, res: Response): Promise<void> {
const query = z.object({
offset: z.coerce.number().int().min(0).max(10000).default(0),
limit: z.coerce.number().int().min(1).max(100).default(20),
}).strict().parse(req.query)
const data = await profiles.find({ skip: query.offset, take: query.limit, order: { id: 'ASC' } })
res.json({ data })
}
export async function getUser(req: Request, res: Response): Promise<void> {
const user = await profiles.findOneBy({ id: idSchema.parse(req.params.id) })
if (!user) throw new AppError(404, 'User not found')
res.json({ data: user })
}
export async function createUser(req: Request, res: Response): Promise<void> {
const { name, email } = profileSchema.parse(req.body)
const user = await profiles.save(profiles.create({ name, email }))
res.location('/api/v1/users/' + user.id).status(201).json({ data: user })
}
export async function updateUser(req: Request, res: Response): Promise<void> {
const id = idSchema.parse(req.params.id)
const fields = patchSchema.parse(req.body)
const result = await profiles.update(id, fields)
if (result.affected === 0) throw new AppError(404, 'User not found')
res.status(204).end()
}
export async function deleteUser(req: Request, res: Response): Promise<void> {
const result = await profiles.delete(idSchema.parse(req.params.id))
if (result.affected === 0) throw new AppError(404, 'User not found')
res.status(204).end()
}
Las actualizaciones y eliminaciones devuelven una respuesta 204 vacía. Los emails normalizados duplicados devuelven 409. Las lecturas devuelven solo los campos públicos del modelo de perfil.
Rutas de usuarios
Crea src/routes/userRoutes.ts. La autenticación se realiza antes del análisis del JSON.
import { json, Router } from 'express'
import { createUser, deleteUser, getUser, getUsers, updateUser } from '../controllers/userController.js'
import { authorize } from '../middleware/auth.js'
export const userRoutes = Router()
const body = json({ limit: '10kb' })
userRoutes.get('/', authorize('profiles:read'), getUsers)
userRoutes.get('/:id', authorize('profiles:read'), getUser)
userRoutes.post('/', authorize('profiles:write'), body, createUser)
userRoutes.patch('/:id', authorize('profiles:write'), body, updateUser)
userRoutes.delete('/:id', authorize('profiles:write'), deleteUser)
Ejecuta la aplicación
Añade estos scripts a package.json, conservando sus dependencias y "type": "module":
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Con el entorno del servidor configurado y la base de datos local en ejecución:
yarn build
yarn start
Prueba la API
Obtén un token de corta duración de tu proveedor de identidad mediante su flujo de inicio de sesión
administrativo. Establece ACCESS_TOKEN solo en tu shell de pruebas local;
no lo incluyas en el código fuente del frontend ni en los registros.
curl --fail-with-body http://localhost:3000/api/v1/users \
-H "Authorization: Bearer $ACCESS_TOKEN"
curl --fail-with-body -X POST http://localhost:3000/api/v1/users \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"John Doe","email":"john@example.com"}'
# Set PROFILE_ID to the UUID returned by POST.
curl --fail-with-body "http://localhost:3000/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
curl --fail-with-body -X PATCH "http://localhost:3000/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"John Smith"}'
curl --fail-with-body -X DELETE "http://localhost:3000/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Prueba también los tokens ausentes o vencidos (401), los alcances ausentes (403), los UUID con formato
incorrecto o los campos adicionales (400), los emails duplicados (409), el JSON que supera el tamaño
máximo (413) y los registros eliminados (404). Cada respuesta debe tener un
X-Request-ID nuevo; los cuerpos de error deben contener ese mismo ID y ningún
rastro de la pila de llamadas.
Documentación de la API
Documenta en OpenAPI el esquema de tokens de portador, los alcances requeridos, la paginación, los esquemas de entrada y los códigos de respuesta. Incluye el encabezado de ID de solicitud y las respuestas 204 vacías. Consulta la especificación de OpenAPI.
Consideraciones para el despliegue
Esta es una demostración CRUD completa, no un sistema completo de identidad ni de operaciones en producción. Antes del despliegue, configura HTTPS, las migraciones revisadas, las copias de seguridad de la base de datos, la rotación de claves del emisor, la política de revocación de tokens, los límites de solicitudes y la retención de registros de auditoría. Mantén los detalles de la base de datos y de los tokens fuera de los registros. Usa credenciales de base de datos separadas con solo los permisos que necesita esta aplicación.
Conclusión
Express gestiona las rutas, TypeORM almacena los perfiles de forma persistente y TypeScript describe las interfaces. La validación en tiempo de ejecución y los alcances verificados de los tokens de acceso hacen cumplir los límites que los tipos estáticos por sí solos no pueden garantizar. La API de subida de Transloadit puede complementar esta API cuando tu aplicación también necesita recibir y procesar archivos.
