RESTful APIs mit Node.js, Express und TypeScript erstellen
In dieser Anleitung erstellen Sie mit Express, TypeScript und PostgreSQL eine API zur administrativen Verwaltung von Benutzerprofilen. Sie behandelt authentifizierte CRUD-Operationen, Validierung, Datenbankpersistenz und einheitliche Fehler. Die API speichert Profildaten; Ihr Identitätsanbieter verwaltet die Anmeldung und die Speicherung von Passwörtern.
Einführung
Die API richtet sich an vertrauenswürdige Administratoren mit signierten Zugriffstokens.
Ein Token benötigt den Berechtigungsumfang profiles:read, um Profile zu lesen,
und profiles:write, um sie zu erstellen, zu bearbeiten oder zu löschen.
Diese Berechtigungsumfänge erlauben den Zugriff auf alle Profile; dies ist keine API zur
Selbstverwaltung durch Benutzer. Der vertrauenswürdige Aussteller weist die Berechtigungsumfänge zu.
Anfragekörper können weder eine Identität für die Autorisierung noch eine Rolle festlegen.
Systemanforderungen
Verwenden Sie Node.js 24, Yarn 4 und eine lokale PostgreSQL-17-Datenbank. Das Beispiel legt Express 5.2.1, TypeORM 1.1.1 und TypeScript 5.9.3 als Versionen fest. Express 5 leitet abgelehnte Promises aus Routenhandlern an die Fehler-Middleware weiter.
Sie benötigen außerdem einen Identitätsanbieter, der RS256-JWT-Zugriffstokens mit Aussteller,
Zielgruppe, Subjekt, Ablaufzeit und dem Claim scope ausstellt, dessen Werte
durch Leerzeichen getrennt sind. Beziehen Sie den öffentlichen Prüfschlüssel über den
vertrauenswürdigen Konfigurationskanal Ihres Anbieters.
Umgebung einrichten
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
Setzen Sie "type": "module" in package.json.
Erstellen Sie tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
}
Verwenden Sie eine ausschließlich serverseitige Umgebungskonfiguration:
DATABASE_URL, JWT_ISSUER, JWT_AUDIENCE
und JWT_PUBLIC_KEY (der öffentliche PEM-Schlüssel mit tatsächlichen Zeilenumbrüchen).
Der Standardwert für PORT ist 3000.
Setzen Sie SCHEMA_SYNC=development-only nur für eine entbehrliche lokale Datenbank.
Halten Sie Datenbankzugangsdaten aus der Versionsverwaltung heraus und deaktivieren Sie niemals
die TLS-Prüfung für eine entfernte Datenbank.
Projektstruktur
src/
├── config/database.ts
├── controllers/userController.ts
├── middleware/errorHandler.ts
├── middleware/auth.ts
├── models/User.ts
├── routes/userRoutes.ts
├── app.ts
└── index.ts
Datenbankverbindung einrichten
Erstellen Sie 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],
})
Die Importspezifizierer mit .js verweisen in diesem eigenständigen
NodeNext-Projekt auf die erzeugten Dateien. Kompilieren Sie mit TypeScript: Das Entfernen von
Typannotationen durch Node transformiert die Dekoratoren von TypeORM nicht.
Verwenden Sie geprüfte TypeORM-Migrationen
für bereitgestellte Datenbanken.
Benutzermodell erstellen
Erstellen Sie 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
}
Es gibt keine Passwortspalte. Das Erstellen eines Profils erzeugt weder ein Anmeldekonto noch gewährt es Zugriff.
Middleware zur Fehlerbehandlung
Erstellen Sie src/middleware/errorHandler.ts. Erzeugen Sie für jede Anfrage eine neue Anfrage-ID;
vertrauen Sie niemals dem Anfrage-ID-Header eines Clients als Audit-Kennung.
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 })
}
Erstellen Sie src/middleware/auth.ts. Der konfigurierte öffentliche Schlüssel, Aussteller,
die Zielgruppe und der Algorithmus sind serverseitige Vorgaben; kein Token-Header oder
Anfrageparameter kann sie ersetzen. Die JWT-Prüfung von jose
prüft die Signatur und die Claims.
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()
}
}
Express-Server einrichten
Erstellen Sie 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)
Erstellen Sie 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
})
Binden Sie den Server für die Bereitstellung hinter einem HTTPS-Reverse-Proxy ein. Machen Sie keinen HTTP-Endpunkt für die Entwicklung öffentlich zugänglich und fügen Sie keine freizügigen CORS-Regeln hinzu, um einen Browser-Client zum Laufen zu bringen.
Benutzer-Controller
Erstellen Sie src/controllers/userController.ts. Strikte Schemas weisen zusätzliche Felder zurück,
anstatt id, password,
role oder Zeitstempel stillschweigend zu akzeptieren.
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()
}
Aktualisierungen und Löschungen geben eine leere Antwort mit Statuscode 204 zurück. Doppelte normalisierte E-Mail-Adressen führen zu Statuscode 409. Lesezugriffe geben nur die öffentlichen Felder des Profilmodells zurück.
Benutzerrouten
Erstellen Sie src/routes/userRoutes.ts. Die Authentifizierung erfolgt vor dem JSON-Parsing.
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)
Anwendung ausführen
Fügen Sie diese Skripte zu package.json hinzu und behalten Sie dabei
die Abhängigkeiten und "type": "module" bei:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Wenn die Serverumgebung konfiguriert ist und die lokale Datenbank läuft, führen Sie Folgendes aus:
yarn build
yarn start
API testen
Beziehen Sie über den administrativen Anmeldeablauf Ihres Identitätsanbieters ein kurzlebiges Token.
Setzen Sie ACCESS_TOKEN nur in Ihrer lokalen Test-Shell; speichern Sie es weder
im Frontend-Quellcode noch in Protokollen.
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"
Testen Sie auch fehlende oder abgelaufene Tokens (401), fehlende Berechtigungsumfänge (403),
fehlerhafte UUIDs oder zusätzliche Felder (400), doppelte E-Mail-Adressen (409), zu große
JSON-Anfragen (413) und gelöschte Datensätze (404). Jede Antwort sollte einen neuen Wert für
X-Request-ID enthalten; Fehlerantwortkörper sollten dieselbe ID und
keinen Stacktrace enthalten.
API-Dokumentation
Dokumentieren Sie das Bearer-Token-Schema, die erforderlichen Berechtigungsumfänge, die Paginierung, die Eingabeschemas und die Antwortcodes in OpenAPI. Nehmen Sie den Anfrage-ID-Header und die leeren Antworten mit Statuscode 204 auf. Weitere Informationen finden Sie in der OpenAPI-Spezifikation.
Überlegungen zur Bereitstellung
Dies ist eine vollständige CRUD-Demonstration, kein vollständiges System für Identitätsverwaltung oder den Produktivbetrieb. Konfigurieren Sie vor der Bereitstellung HTTPS, geprüfte Migrationen, Datenbanksicherungen, die Schlüsselrotation des Ausstellers, eine Richtlinie zum Widerruf von Tokens, Ratenbegrenzungen und die Aufbewahrung von Audit-Protokollen. Halten Sie Datenbank- und Token-Details aus Protokollen heraus. Verwenden Sie separate Datenbankzugangsdaten, die nur die Berechtigungen besitzen, die diese Anwendung benötigt.
Fazit
Express übernimmt das Routing, TypeORM speichert Profile dauerhaft und TypeScript beschreibt die Schnittstellen. Laufzeitvalidierung und geprüfte Berechtigungsumfänge der Zugriffstokens setzen Grenzen durch, die statische Typen allein nicht gewährleisten können. Die Upload-API von Transloadit kann diese API ergänzen, wenn Ihre Anwendung auch Dateien entgegennehmen und verarbeiten muss.
