Creación de API RESTful con Node.js, Express y TypeScript
Crea una API administrativa de perfiles de usuario con Express, TypeScript y PostgreSQL; luego crea, lee, actualiza y elimina un perfil mediante HTTP. Generarás tokens de prueba locales y reiniciarás la API para verificar que el perfil se conserva. La API almacena datos de perfiles; no implementa inicio de sesión ni almacenamiento de contraseñas.
Introducción
La API atiende a administradores de confianza con tokens de acceso firmados. Un token necesita el
ámbito profiles:read para leer perfiles y profiles:write para crearlos,
editarlos o eliminarlos. Estos ámbitos autorizan el acceso a todos los perfiles; no se trata de
una API de autoservicio para usuarios. El emisor de confianza asigna los ámbitos. Los cuerpos de
las solicitudes no pueden elegir una identidad ni un rol de autorización.
Requisitos del sistema
Usa Bash en Linux, Node.js 24.21.0, Corepack, cURL y un Docker Engine en ejecución. Corepack ejecuta la versión fijada de Yarn 4.12.0 del proyecto sin una instalación global de Yarn. El ejemplo usa PostgreSQL 17.11, Express 5.2.1, TypeORM 1.1.1 y TypeScript 5.9.3. Node.js 24 es una versión LTS que recibe mantenimiento. Express 5 reenvía las promesas rechazadas de los manejadores de rutas al middleware de errores.
El firmante local que se muestra a continuación proporciona JWT RS256 con emisor, audiencia, sujeto, vencimiento y ámbitos separados por espacios. Te permite probar la verificación real de firmas sin una cuenta de proveedor de identidad. Cualquiera que tenga su clave privada puede conceder acceso de administrador, así que úsalo solo para esta demostración en la interfaz de loopback.
Configuración del entorno
Ejecuta los pasos de configuración en orden y detente ante cualquier error. Si
stack-api ya existe, elige otro directorio en lugar de sobrescribirlo.
El subshell conserva tu directorio original si falla la configuración:
(
mkdir stack-api && cd stack-api &&
mkdir -p src/config src/controllers src/middleware src/models src/routes src/local
) && cd stack-api
Guarda este package.json completo:
{
"name": "stack-api",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"express": "5.2.1",
"helmet": "8.3.0",
"jose": "6.2.12",
"pg": "8.23.0",
"reflect-metadata": "0.2.2",
"typeorm": "1.1.1",
"zod": "3.25.76"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "24.10.1",
"typescript": "5.9.3"
}
}
Guarda .yarnrc.yml para usar una instalación de
node_modules local al proyecto:
nodeLinker: node-modules
enableGlobalCache: false
enableTelemetry: false
ignorePath: true
injectEnvironmentFiles: []
Crea tsconfig.json. Los valores explícitos de types y
typeRoots impiden que el compilador incluya tipos de entorno ajenos de un
proyecto contenedor:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"],
"typeRoots": ["./node_modules/@types"],
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}
Ahora realiza la instalación. Un yarn.lock vacío establece este directorio
como un proyecto de Yarn independiente, incluso dentro de un
espacio de trabajo. Vaciar NODE_OPTIONS impide que el cargador Plug’n’Play de un
proyecto contenedor se inyecte en estos comandos:
touch yarn.lock && NODE_OPTIONS='' corepack yarn install
Conserva el archivo de bloqueo generado para las instalaciones posteriores. No incluyas claves
locales de firma ni credenciales de base de datos en el control de versiones; ignora
.local/ en tu propio proyecto.
Estructura del proyecto
src/
├── config/database.ts
├── controllers/userController.ts
├── middleware/errorHandler.ts
├── middleware/auth.ts
├── models/User.ts
├── routes/userRoutes.ts
├── local/createKeys.ts
├── local/issueToken.ts
├── app.ts
└── index.ts
Configuración de la conexión a la base de datos
Inicia una base de datos desechable con la imagen oficial de PostgreSQL. Docker asigna un puerto disponible en la interfaz de loopback. La contraseña siguiente es solo para este ejemplo local; el usuario de la base de datos del contenedor es un superusuario, que no es adecuado para una aplicación desplegada.
: "${DB_CONTAINER:=stack-api-db-${RANDOM}-${RANDOM}}"
docker run --detach --name "$DB_CONTAINER" \
--publish 127.0.0.1::5432 \
--tmpfs /var/lib/postgresql/data:rw,size=256m \
--env POSTGRES_USER=profiles --env POSTGRES_DB=profiles \
--env POSTGRES_PASSWORD=local-only-password postgres:17.11-bookworm
Comprueba que la conexión TCP esté lista, con 30 reintentos y una pausa de un segundo entre ellos. Continúa solo si este bloque se ejecuta correctamente:
(
for attempt in {1..30}; do
if docker exec "$DB_CONTAINER" pg_isready -h 127.0.0.1 -U profiles -d profiles; then
exit 0
fi
sleep 1
done
printf 'PostgreSQL did not become ready; inspect docker logs.\n' >&2
exit 1
)
Mantén abierta esta terminal: la configuración y la limpieza posteriores usan
DB_CONTAINER. La base de datos sigue disponible mientras se reinicia la API,
pero sus datos temporales desaparecen cuando se detiene el contenedor de la base de datos.
Este ejercicio es local de forma deliberada.
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
emitidos 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.
Creación del 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({ type: 'timestamptz' })
createdAt!: Date
}
No hay una columna de contraseña. Crear un perfil no crea una cuenta de inicio de sesión ni
concede acceso.
timestamptz
almacena la hora de creación como un instante, independiente de la zona horaria de la sesión de
la base de datos.
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 son políticas del servidor; ningún encabezado de token ni parámetro de solicitud
puede reemplazarlos. jose verifica la firma y las declaraciones mediante
estas opciones de verificación.
El servidor exige sub, exp y
iat, y rechaza los tokens emitidos hace más de 15 minutos, aunque su
vencimiento sea posterior. Tu emisor de producción también debe proporcionar estas 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()
}
}
Configuración del 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 { once } from 'node:events'
import { z } from 'zod'
import { app } from './app.js'
import { AppDataSource } from './config/database.js'
async function main(): Promise<void> {
const port = z.coerce.number().int().min(1).max(65535).parse(process.env.PORT ?? 3000)
await AppDataSource.initialize()
const server = app.listen(port, '127.0.0.1')
server.requestTimeout = 30_000
server.headersTimeout = 10_000
await once(server, 'listening')
console.log(`Listening on http://127.0.0.1:${port}`)
}
main().catch(async () => {
console.error('API startup failed; check database, JWT settings, and PORT')
if (AppDataSource.isInitialized) await AppDataSource.destroy()
process.exitCode = 1
})
El mensaje que indica que el servidor está listo aparece solo después de que se establece la
conexión con PostgreSQL y se enlaza el puerto HTTP. Si el inicio falla, revisa el contenedor de
la base de datos, la configuración de las claves y si otro proceso ya usa
PORT. Elige un puerto libre en lugar de detener un proceso desconocido.
Controlador de usuarios
Crea src/controllers/userController.ts. Los esquemas estrictos rechazan campos adicionales en lugar
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)
Ejecución de la aplicación
Crea src/local/createKeys.ts. Ejecútalo una vez por cada proyecto nuevo; no permite
sobrescribir claves:
import { generateKeyPairSync } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
const pair = generateKeyPairSync('rsa', { modulusLength: 2048 })
await mkdir('.local', { mode: 0o700 })
await writeFile('.local/private.pem', pair.privateKey.export({ type: 'pkcs8', format: 'pem' }), {
flag: 'wx', mode: 0o600,
})
await writeFile('.local/public.pem', pair.publicKey.export({ type: 'spki', format: 'pem' }), {
flag: 'wx', mode: 0o600,
})
Crea src/local/issueToken.ts. Este emisor de prueba local concede ambos ámbitos de
administrador durante cinco minutos. Es una herramienta auxiliar de línea de comandos, no una
ruta de inicio de sesión de la API:
import { readFile } from 'node:fs/promises'
import { importPKCS8, SignJWT } from 'jose'
const key = await importPKCS8(await readFile('.local/private.pem', 'utf8'), 'RS256')
const token = await new SignJWT({ scope: 'profiles:read profiles:write' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuer('https://local-issuer.example.test')
.setAudience('profile-api')
.setSubject('local-admin')
.setIssuedAt()
.setExpirationTime('5m')
.sign(key)
console.log(token)
Compila el código TypeScript y crea las claves. El && impide ejecutar
una compilación anterior si la compilación falla:
NODE_OPTIONS='' corepack yarn build && NODE_OPTIONS='' node dist/local/createKeys.js
Configura el servidor en la misma terminal en la que iniciaste PostgreSQL.
JWT_PUBLIC_KEY contiene el texto PEM con saltos de línea reales.
SCHEMA_SYNC habilita la creación de tablas solo para esta base de datos
desechable; déjalo sin definir para una base de datos desplegada que se gestione con migraciones.
DB_ADDRESS="$(docker port "$DB_CONTAINER" 5432/tcp)" &&
LOCAL_PUBLIC_KEY="$(cat .local/public.pem)" &&
export DATABASE_URL="postgresql://profiles:local-only-password@${DB_ADDRESS}/profiles" \
JWT_PUBLIC_KEY="$LOCAL_PUBLIC_KEY" \
JWT_ISSUER='https://local-issuer.example.test' JWT_AUDIENCE='profile-api' \
SCHEMA_SYNC=development-only PORT="${PORT:-3000}"
Inicia la API en primer plano. Espera a que aparezca Listening on http://127.0.0.1:3000, o el puerto
que hayas seleccionado:
NODE_OPTIONS='' corepack yarn start
Pruebas de la API
Abre una segunda terminal en stack-api. Define también allí
PORT si seleccionaste otro puerto.
Ejecuta estos pasos en esa misma segunda terminal para que el token y el UUID devuelto sigan
disponibles. No incluyas tokens en los registros ni en el código fuente del frontend. Para renovar
un token vencido, repite el comando del token.
BASE_URL="http://127.0.0.1:${PORT:-3000}"
NEXT_ACCESS_TOKEN="$(NODE_OPTIONS='' node dist/local/issueToken.js)" && ACCESS_TOKEN="$NEXT_ACCESS_TOKEN"
Crea el perfil y captura el UUID devuelto solo después de una respuesta exitosa. Si la solicitud falla, no continúes con el siguiente paso. Un 409 significa que ese email ya existe; usa otro email o lee el perfil existente en lugar de reintentar a ciegas un POST que quizá ya se haya confirmado.
CREATED="$(curl -fsS -X POST "$BASE_URL/api/v1/users" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"John Doe","email":"john@example.com"}')" &&
NEXT_PROFILE_ID="$(printf '%s' "$CREATED" | NODE_OPTIONS='' node --input-type=module -e '
import { z } from "zod"
let text = ""
for await (const chunk of process.stdin) text += chunk
const { data } = z.object({ data: z.object({ id: z.string().uuid() }) }).parse(JSON.parse(text))
console.log(data.id)
')" && PROFILE_ID="$NEXT_PROFILE_ID" && printf '%s\n' "$CREATED"
La respuesta es {"data":{"id":"…","name":"John Doe","email":"john@example.com","createdAt":"…"}},
con HTTP 201 y un encabezado Location para el nuevo perfil.
Lee el perfil y la lista paginada:
curl -fsS "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" &&
curl -fsS "$BASE_URL/api/v1/users?limit=20&offset=0" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Actualiza el nombre. -i muestra el estado y los encabezados porque
PATCH no devuelve un cuerpo:
curl -fsS -i -X PATCH "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"John Smith"}'
La respuesta esperada es HTTP 204. En la primera terminal, presiona Ctrl+C y luego repite el bloque de inicio anterior. Deja PostgreSQL en ejecución. En la segunda terminal, repite el siguiente GET; el mismo UUID, el nombre actualizado, el email y la hora de creación deberían seguir presentes:
curl -fsS "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Elimina ese perfil y luego observa su respuesta 404. El último comando omite deliberadamente
-f para que puedas leer el JSON del error:
curl -fsS -i -X DELETE "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" &&
curl -sS -i "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
DELETE devuelve un 204 vacío. El GET siguiente devuelve 404 con error y
requestId. Cada respuesta tiene un X-Request-ID nuevo;
el ID del cuerpo de un error coincide con el de su encabezado. La autenticación se ejecuta antes
del análisis del cuerpo, y el analizador JSON limita a 10 KiB el cuerpo decodificado de la
solicitud.
Documentación de la API
Usa estos resultados al describir esta API en OpenAPI:
| Solicitud | Estado |
|---|---|
| Crear un perfil válido | 201, con Location |
| Leer un perfil o una lista paginada | 200 |
| Actualizar o eliminar un perfil existente | 204, cuerpo vacío |
| Token ausente, vencido, con firma incorrecta o con emisor/audiencia incorrectos | 401 |
| Token válido sin el ámbito requerido por la ruta | 403 |
| UUID, campos o paginación no válidos, o JSON mal formado | 400 |
| Email duplicado tras quitar espacios de los extremos y convertir a minúsculas | 409 |
| Cuerpo JSON de más de 10 KiB | 413 |
| UUID de perfil desconocido o eliminado | 404 |
Las escrituras de perfiles aceptan solo name y
email. PATCH requiere al menos uno de ellos, y la paginación usa de forma
predeterminada 20 elementos, con un máximo de 100 y un desplazamiento entre cero y 10.000.
Documenta los dos ámbitos como permisos de administrador sobre todos los perfiles e incluye el
esquema de token Bearer y el encabezado de ID de solicitud.
Consideraciones de despliegue
Para una aplicación con usuarios reales, reemplaza el firmante local por el flujo de tokens de acceso administrativo de tu proveedor de identidad. Obtén su clave pública mediante una configuración de confianza y define el emisor y la audiencia correspondientes en el servidor. Este ejemplo usa una clave de verificación fija; no implementa rotación de claves ni revocación de tokens.
Mantén la API detrás de HTTPS para el acceso remoto, reemplaza la base de datos desechable que usa un superusuario por una con credenciales de aplicación restringidas y usa migraciones en lugar de sincronización del esquema. Detener el proceso de Node no detiene PostgreSQL. Cuando termines, detén la API y elimina solo el contenedor de base de datos que creaste, descartando sus datos temporales:
docker rm --force --volumes "$DB_CONTAINER"
Conclusión
Si más adelante estos perfiles necesitan archivos adjuntos, la API de subida de Transloadit proporciona un servicio independiente de recepción y procesamiento.
