Créer des API RESTful avec Node.js, Express et TypeScript
Créez une API d’administration de profils utilisateur avec Express, TypeScript et PostgreSQL, puis créez, lisez, modifiez et supprimez un profil via HTTP. Vous générerez des jetons de test locaux et redémarrerez l’API pour vérifier que le profil persiste. L’API stocke les données des profils ; elle n’implémente ni la connexion des utilisateurs ni le stockage des mots de passe.
Introduction
L’API s’adresse à des administrateurs de confiance munis de jetons d’accès signés. Un jeton doit
avoir la portée profiles:read pour lire les profils et
profiles:write pour les créer, les modifier ou les supprimer.
Ces portées autorisent l’accès à tous les profils ; il ne s’agit pas d’une API utilisateur
en libre-service. L’émetteur de confiance attribue les portées. Les corps de requête ne peuvent
pas choisir d’identité ni de rôle pour l’autorisation.
Prérequis système
Utilisez Bash sous Linux, Node.js 24.21.0, Corepack, cURL et Docker Engine en cours d’exécution. Corepack exécute la version Yarn 4.12.0 fixée par le projet sans installation globale de Yarn. L’exemple utilise PostgreSQL 17.11, Express 5.2.1, TypeORM 1.1.1 et TypeScript 5.9.3. Node.js 24 est une version LTS maintenue. Express 5 transmet les promesses rejetées des gestionnaires de routes au middleware de gestion des erreurs.
L’outil de signature local ci-dessous fournit des JWT RS256 avec un émetteur, une audience, un sujet, une expiration et des portées séparées par des espaces. Il vous permet de tester une véritable vérification de signature sans compte auprès d’un fournisseur d’identité. Quiconque détient sa clé privée peut accorder un accès administrateur : utilisez-le donc uniquement pour cette démonstration sur l’interface de bouclage.
Configurer l’environnement
Exécutez les étapes de configuration dans l’ordre et arrêtez-vous à la moindre erreur. Si
stack-api existe déjà, choisissez un autre répertoire plutôt que de l’écraser.
Le sous-shell conserve votre répertoire d’origine en cas d’échec de la configuration :
(
mkdir stack-api && cd stack-api &&
mkdir -p src/config src/controllers src/middleware src/models src/routes src/local
) && cd stack-api
Enregistrez ce fichier package.json dans son intégralité :
{
"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"
}
}
Enregistrez .yarnrc.yml pour utiliser une installation de
node_modules locale au projet :
nodeLinker: node-modules
enableGlobalCache: false
enableTelemetry: false
ignorePath: true
injectEnvironmentFiles: []
Créez tsconfig.json. Spécifier explicitement types et
typeRoots empêche le compilateur d’inclure des types ambiants sans rapport
provenant d’un projet englobant :
{
"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"]
}
Procédez maintenant à l’installation. Un fichier yarn.lock vide fait de ce
répertoire un projet Yarn distinct, y compris à l’intérieur d’un
espace de travail. Vider NODE_OPTIONS empêche l’injection du chargeur Plug’n’Play
d’un projet englobant dans ces commandes :
touch yarn.lock && NODE_OPTIONS='' corepack yarn install
Conservez le fichier de verrouillage généré pour les installations suivantes. Ne placez pas les
clés de signature locales ni les informations d’authentification de la base de données dans le
dépôt de code ; ignorez .local/ dans votre propre projet.
Structure du projet
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
Configurer la connexion à la base de données
Démarrez une base de données jetable avec l’image officielle PostgreSQL. Docker attribue un port disponible sur l’interface de bouclage. Le mot de passe ci-dessous est uniquement destiné à cet exemple local ; l’utilisateur de la base de données du conteneur est un superutilisateur, inadapté à une application déployée.
: "${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
Vérifiez que le service est prêt à accepter des connexions TCP, en réessayant 30 fois avec une pause d’une seconde entre les tentatives. Ne continuez que si ce bloc réussit :
(
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
)
Gardez ce terminal ouvert : la configuration et le nettoyage ultérieurs utilisent
DB_CONTAINER. La base de données reste disponible pendant les redémarrages de
l’API, mais ses données temporaires disparaissent à l’arrêt du conteneur de base de données.
Cet exercice est délibérément local.
Créez 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],
})
Les spécificateurs d’importation .js désignent les fichiers générés dans
ce projet NodeNext autonome. Compilez avec TypeScript :
la suppression des types par Node ne transforme pas les décorateurs
de TypeORM. Utilisez des migrations TypeORM ayant fait l’objet d’une
revue pour les bases de données déployées.
Créer le modèle utilisateur
Créez 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
}
Il n’y a pas de colonne de mot de passe. Créer un profil ne crée pas de compte de connexion et
n’accorde aucun accès. timestamptz
stocke la date et l’heure de création sous forme d’un instant, indépendamment du fuseau horaire
de la session de base de données.
Middleware de gestion des erreurs
Créez src/middleware/errorHandler.ts. Générez un nouvel identifiant de requête pour chaque requête ;
ne faites jamais confiance à l’en-tête d’identifiant de requête d’un client comme identifiant
d’audit.
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 })
}
Créez src/middleware/auth.ts. La clé publique, l’émetteur, l’audience et l’algorithme
configurés relèvent de la politique du serveur ; aucun en-tête de jeton ni paramètre de requête
ne peut les remplacer. jose vérifie la signature et les revendications à l’aide de
ces options de vérification.
Le serveur exige sub, exp et
iat, et rejette les jetons émis il y a plus de 15 minutes, même si leur
expiration est ultérieure. Votre émetteur de production doit aussi fournir ces revendications.
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()
}
}
Configurer le serveur Express
Créez 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)
Créez 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
})
Le message indiquant que le serveur est prêt n’apparaît qu’une fois la connexion à PostgreSQL
établie et la liaison au port HTTP effectuée. Si le démarrage échoue, vérifiez le conteneur de
base de données et la configuration des clés, puis vérifiez si un autre processus utilise déjà
PORT. Choisissez un port libre plutôt que d’arrêter un processus que vous
ne connaissez pas.
Contrôleur utilisateur
Créez src/controllers/userController.ts. Les schémas stricts rejettent les champs supplémentaires au
lieu d’accepter silencieusement id, password,
role ou des horodatages.
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()
}
Les mises à jour et les suppressions renvoient une réponse 204 vide. Les doublons d’adresses e-mail normalisées donnent lieu à une réponse 409. Les lectures ne renvoient que les champs publics du modèle de profil.
Routes utilisateur
Créez src/routes/userRoutes.ts. L’authentification a lieu avant l’analyse du 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)
Exécuter l’application
Créez src/local/createKeys.ts. Exécutez-le une seule fois par nouveau projet ; il refuse
d’écraser les clés :
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,
})
Créez src/local/issueToken.ts. Cet émetteur de test local accorde les deux portées
administrateur pour cinq minutes. C’est un utilitaire en ligne de commande, et non une route de
connexion à l’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)
Compilez le TypeScript et créez les clés. La présence de && empêche
l’exécution d’une version compilée plus ancienne en cas d’échec de la compilation :
NODE_OPTIONS='' corepack yarn build && NODE_OPTIONS='' node dist/local/createKeys.js
Configurez le serveur dans le même terminal que celui où vous avez démarré PostgreSQL.
JWT_PUBLIC_KEY contient le texte PEM avec de véritables sauts de ligne.
SCHEMA_SYNC active la création de tables uniquement pour cette base de données
jetable ; ne définissez pas ce paramètre pour une base de données déployée gérée à l’aide de
migrations.
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}"
Démarrez l’API au premier plan. Attendez que s’affiche le message d’écoute
Listening on http://127.0.0.1:3000, ou ce même message avec le port que vous avez choisi :
NODE_OPTIONS='' corepack yarn start
Tester l’API
Ouvrez un deuxième terminal dans stack-api. Définissez-y également
PORT si vous avez choisi un autre port. Exécutez ces étapes dans ce même
deuxième terminal pour que le jeton et l’UUID renvoyé restent disponibles. Ne placez pas les
jetons dans les journaux ni dans le code source côté client. Pour renouveler un jeton expiré,
répétez la commande de génération du jeton.
BASE_URL="http://127.0.0.1:${PORT:-3000}"
NEXT_ACCESS_TOKEN="$(NODE_OPTIONS='' node dist/local/issueToken.js)" && ACCESS_TOKEN="$NEXT_ACCESS_TOKEN"
Créez le profil et récupérez l’UUID renvoyé uniquement après une réponse réussie. Si la requête échoue, ne passez pas à l’étape suivante. Un statut 409 signifie que l’adresse e-mail est déjà présente ; utilisez une autre adresse ou lisez le profil existant plutôt que de réessayer aveuglément une requête POST dont les modifications ont peut-être déjà été enregistrées.
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 réponse est {"data":{"id":"…","name":"John Doe","email":"john@example.com","createdAt":"…"}}, avec le statut HTTP 201 et un en-tête
Location pour le nouveau profil. Lisez le profil et la liste paginée :
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"
Modifiez le nom. -i affiche le statut et les en-têtes, car PATCH ne
renvoie pas de corps :
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 réponse attendue est HTTP 204. Dans le premier terminal, appuyez sur Ctrl+C, puis répétez le bloc de démarrage ci-dessus. Laissez PostgreSQL en cours d’exécution. Dans le deuxième terminal, répétez la requête GET suivante ; le même UUID, le nom modifié, l’adresse e-mail et l’heure de création devraient toujours être présents :
curl -fsS "$BASE_URL/api/v1/users/$PROFILE_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Supprimez ce profil, puis observez la réponse 404 le concernant. La dernière commande omet
volontairement -f pour que vous puissiez lire le JSON d’erreur :
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 renvoie une réponse 204 vide. La requête GET suivante renvoie 404 avec
error et requestId.
Chaque réponse porte une nouvelle valeur d’identifiant de requête dans l’en-tête
X-Request-ID ; l’identifiant dans le corps d’une erreur correspond à celui de son
en-tête. L’authentification s’exécute avant l’analyse du corps, et l’analyseur JSON limite le
corps de requête décodé à 10 KiB.
Documentation de l’API
Utilisez ces résultats pour décrire cette API dans OpenAPI :
| Requête | Statut |
|---|---|
| Créer un profil valide | 201, avec Location |
| Lire un profil ou une liste paginée | 200 |
| Modifier ou supprimer un profil existant | 204, corps vide |
| Jeton absent, expiré, mal signé ou avec un émetteur ou une audience incorrects | 401 |
| Jeton valide sans la portée requise par la route | 403 |
| UUID, champs ou pagination invalides, ou JSON mal formé | 400 |
| Adresse e-mail en doublon après suppression des espaces aux extrémités et passage en minuscules | 409 |
| Corps JSON supérieur à 10 KiB | 413 |
| UUID de profil inconnu ou supprimé | 404 |
Les écritures de profils n’acceptent que name et
email. PATCH exige au moins l’un des deux, et la pagination utilise par
défaut 20 éléments, avec un maximum de 100 et un décalage compris entre zéro et 10 000.
Documentez les deux portées comme des permissions d’administrateur sur tous les profils, en
incluant le schéma d’authentification par jeton porteur et l’en-tête d’identifiant de requête.
Considérations relatives au déploiement
Pour une application avec de vrais utilisateurs, remplacez l’outil de signature local par le flux de jetons d’accès administrateur de votre fournisseur d’identité. Obtenez sa clé publique via une configuration de confiance et définissez l’émetteur et l’audience correspondants sur le serveur. Cet exemple utilise une clé de vérification fixe ; il n’implémente ni la rotation des clés ni la révocation des jetons.
Protégez l’accès distant à l’API par HTTPS, remplacez la base de données jetable à accès superutilisateur par une base accessible à l’application avec des informations d’authentification aux droits restreints, et utilisez des migrations plutôt que la synchronisation du schéma. Arrêter le processus Node n’arrête pas PostgreSQL. Une fois l’exercice terminé, arrêtez l’API et supprimez uniquement le conteneur de base de données que vous avez créé, en supprimant ses données temporaires :
docker rm --force --volumes "$DB_CONTAINER"
Conclusion
Si ces profils ont plus tard besoin de fichiers joints, l’API de téléversement de Transloadit fournit un service distinct d’ingestion et de traitement.
