Dieser Artikel hilft Ihnen dabei, eine Express 5-Anwendung mit TypeScript zu erstellen.
Sie werden ein produktionsreifes Projekt mit verschiedenen Tools für Linting, Testing und Typüberprüfung einrichten. Falls Sie neu bei REST-APIs sind, enthält dieser Artikel auch Erklärungen zu grundlegenden Konzepten, die Sie möglicherweise kennen müssen, wie Routing und Authentifizierung.
Und wenn Sie mitprogrammieren, was ich sehr empfehle, werden Sie Test-Driven Development verwenden, um eine vollständige REST-API zu erstellen, die die Grundlage Ihrer nächsten Express-Anwendung bilden kann.
Erstellen Sie zunächst ein neues Verzeichnis für Ihr Express-Projekt.
mkdir express-ts-app
cd express-ts-appInitialisieren Sie anschließend Ihr Projekt mit `npm`:
npm init -yDadurch wird eine `package.json`-Datei in Ihrem Projektverzeichnis erstellt.
Fügen Sie "type": "module" zu der package.json Datei hinzu.
// package.json
{
// ...other properties
"main": "dist/index.js",
"type": "module"
// ...other properties
}Installieren Sie die notwendigen Abhängigkeiten für Express.
npm install expressInstallieren Sie anschließend TypeScript und die erforderlichen Typdefinitionen als Entwicklungsabhängigkeiten.
npm install -D typescript @types/node @types/express tsxFühren Sie den folgenden Befehl aus, um eine TypeScript-Konfigurationsdatei zu initialisieren:
npx tsc --initDadurch wird eine tsconfig.json Datei erstellt. Aktualisieren Sie diese, um das Ausgabeverzeichnis für kompilierte Dateien festzulegen – zusammen mit weiteren Optionen:
// tsconfig.json
{
"compilerOptions": {
"allowJs": true,
"esModuleInterop": true,
"isolatedModules": true,
"lib": [
"ESNext"
],
"module": "NodeNext",
"moduleDetection": "force",
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"outDir": "dist",
"paths": {
"~/*": [
"./src/*"
]
},
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ES2023",
"verbatimModuleSyntax": true
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"src/**/*"
]
}Erstellen Sie eine src Verzeichnis für Ihre TypeScript-Dateien:
mkdir srcIm src Verzeichnis erstellen Sie eine index.ts Datei mit folgendem Inhalt:
// src/index.ts
import express from 'express';
const app = express();
const port = Number(process.env.PORT) || 3000;
app.get('/', (request, response) => {
response.send('Express + TypeScript Server');
});
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});Aktualisieren Sie Ihre package.json um Build- und Run-Skripte hinzuzufügen:
// package.json
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
}build: Kompiliert TypeScript-Dateien zu JavaScript.start: Führt die kompilierte JavaScript-Datei aus.dev: Führt die TypeScript-Datei direkt mit Live-Reload aus. Für die Entwicklung verwenden Sie den folgenden Befehl, um den Server mit Live-Reload zu starten:
npm run devBesuchen Sie http://localhost:3000 um Ihre Einrichtung zu überprüfen.
Später, wenn Sie sich auf die Produktion vorbereiten möchten, erstellen Sie zuerst Ihr Projekt:
npm run buildStarten Sie dann die kompilierte Anwendung:
npm startSie können eine schnelle curl-Anfrage ausführen, um zu überprüfen, ob Ihr Server läuft.
$ curl http://localhost:3000
Express + TypeScript ServerInstallieren Sie ESLint und Prettier, um sicherzustellen, dass Ihr Code konsistenten Stilrichtlinien entspricht und potenzielle Fehler frühzeitig erkannt werden.
npm install --save-dev eslint typescript-eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-simple-import-sort eslint-plugin-unicorn prettier @vitest/eslint-plugin
Erstellen Sie eine prettier.config.js Datei. Ich bevorzuge die folgenden Regeln, aber Sie können sie nach Belieben anpassen.
// prettier.config.js
export default {
arrowParens: 'avoid',
bracketSameLine: false,
bracketSpacing: true,
htmlWhitespaceSensitivity: 'css',
insertPragma: false,
jsxSingleQuote: false,
plugins: [],
printWidth: 80,
proseWrap: 'always',
quoteProps: 'as-needed',
requirePragma: false,
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: false,
};Erstellen Sie als Nächstes eine eslint.config.js Datei.
// eslint.config.js
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import vitest from '@vitest/eslint-plugin';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
eslintPluginUnicorn.configs['flat/recommended'],
{
files: ['**/*.{js,ts}'],
ignores: ['**/*.js', 'dist/**/*', 'node_modules/**/*'],
plugins: {
'simple-import-sort': simpleImportSort,
},
rules: {
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'unicorn/better-regex': 'warn',
'unicorn/no-process-exit': 'off',
'unicorn/no-array-reduce': 'off',
'unicorn/prevent-abbreviations': [
'error',
{ replacements: { params: false } },
],
},
},
{
files: ['src/**/*.test.{js,ts}'],
...vitest.configs.recommended,
},
eslintPluginPrettierRecommended,
);Diese Konfiguration kombiniert mehrere ESLint-Regelsätze.
Sie beginnt mit der Erweiterung der empfohlenen JavaScript- und TypeScript-Regeln, fügt dann die Vorschläge des Unicorn-Plugins zur Codeverbesserung hinzu und passt dabei einige ihrer Regeln an (z. B. Warnung für bessere Regex-Nutzung, Deaktivierung von Prozess-Exit-Prüfungen und Anpassung der Abkürzungsvermeidung).
Sie enthält auch das simple-import-sort Plugin, um Ihre Import- und Export-Anweisungen automatisch zu sortieren, wobei Abweichungen als Fehler behandelt werden.
Für Testdateien werden die empfohlenen Vitest-Regeln angewendet, um sicherzustellen, dass Tests Best Practices folgen.
Schließlich wird das Prettier-Plugin hinzugefügt, um die Codeformatierung in Ihren Linting-Prozess zu integrieren, sodass Ihr Code sowohl syntaktisch korrekt als auch konsistent formatiert bleibt.
Fügen Sie Skripte für Linting und Formatierung zu Ihrer package.json Datei:
// package.json
"scripts": {
"format": "prettier --write .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
}Um Test-Driven Development (TDD) zu ermöglichen, installieren Sie ein Test-Framework zusammen mit Vitest und Supertest.
npm install -D vitest vite-tsconfig-paths supertest @types/supertest @faker-js/fakerErstellen Sie eine vitest.config.ts Datei.
// vitest.config.ts
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: { environment: 'node' },
});Dies konfiguriert Vitest so, dass es die tsconfig.json Datei mit tsconfig-paths und in einer Node-Umgebung ausgeführt wird.
Fügen Sie ein Test-Skript zu Ihrer package.json Datei hinzu.
// package.json
"scripts": {
"test": "vitest"
}Ihre src/index.ts Datei erfüllt derzeit zwei Zwecke gleichzeitig. Sie fungiert sowohl als App als auch als Server.
Im Kontext der Entwicklung einer REST-API mit Express bezieht sich die „App“ auf Ihre Express-Anwendung. Sie enthält Middleware und Routen und verarbeitet HTTP-Anfragen. Mit anderen Worten, die App ist die Logik, die auf dem Server läuft.
Der „Server“ ist ein HTTP-Server. Er lauscht auf Netzwerkverbindungen und wird erstellt, wenn Sie aufrufen app.listen().
Löschen Sie Ihre src/index.ts -Datei und erstellen Sie eine neue Datei src/app.ts mit folgendem Inhalt:
// src/app.ts
import express from 'express';
export function buildApp() {
const app = express();
// Middleware for JSON parsing.
app.use(express.json());
return app;
}Sie müssen die express.json() -Middleware konfigurieren, damit Ihre App JSON-Daten aus eingehenden Anfragen verarbeiten kann.
Erstellen Sie nun eine src/server.ts -Datei.
// src/server.ts
import { buildApp } from './app.js';
const port = Number(process.env.PORT) || 3000;
const app = buildApp();
// Start the server and capture the returned Server instance.
const server = app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
// Listen for the SIGTERM signal to gracefully shut down the server.
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
});
});Beachten Sie die .js -Erweiterung beim Importieren der app.ts -Datei. Bei Verwendung von "module": "NodeNext" in Ihrer tsconfig.json -Datei, folgt TypeScript der ES-Modulauflösung von Node.js, was explizite Dateierweiterungen in Importen erfordert. Obwohl Sie Ihren Code in TypeScript schreiben, wird er zu JavaScript kompiliert, daher müssen Sie die .js -Dateien (z. B., import { buildApp } from './app.js';). Dies stellt sicher, dass Node.js die richtigen Dateien zur Laufzeit findet und Fehler verhindert.
Beim Schreiben von Servern möchten Sie das Verhalten Ihrer Anwendung durch die Verfolgung von Anfragen überwachen, was Ihnen bei der Fehlersuche helfen kann. Ein gängiger Ansatz ist die Verwendung von Middleware wie morgan.
Installieren Sie es und die Typen.
npm i morgan && npm i -D @types/morganFügen Sie es Ihrer App hinzu.
// src/server.ts
import morgan from 'morgan';
import { buildApp } from './app.js';
const port = Number(process.env.PORT) || 3000;
const app = buildApp();
// Configure morgan logging based on environment.
const environment = process.env.NODE_ENV || 'development';
app.use(environment === 'development' ? morgan('dev') : morgan('tiny'));
// Start the server and capture the returned Server instance.
const server = app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
// Listen for the SIGTERM signal to gracefully shut down the server.
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
});
});Sie können morgandas Protokollierungsformat basierend auf der Umgebung Ihrer App konfigurieren. Das dev -Format bietet farbige Protokolle für die lokale Entwicklung, während tiny minimale Protokolle für die Produktion bietet.
Es ist am besten, die morgan Middleware in server.ts einzurichten, da Ihre Tests nur die buildApp() Funktion verwenden. Eine Platzierung in app.ts würde Ihre Testausgabe mit unnötigen Logs überladen.
Bevor Sie mit der Implementierung der ersten Funktionen beginnen, lassen Sie uns die allgemeine Struktur einer Express-Anwendung besprechen.
Im weiteren Verlauf dieses Tutorials werden Sie Dateien nach Funktionen gruppieren. Hier ist eine typische Dateistruktur für eine Express-Anwendung, wenn Sie nach Funktionen gruppieren:
.
├── eslint.config.js
├── package-lock.json
├── package.json
├── prettier.config.js
├── src
│ ├── app.ts
│ ├── features
│ │ ├── ... other features ...
│ │ └── feature
│ │ ├── ...
│ │ ├── feature-model.ts
│ │ ├── feature-controller.ts
│ │ ├── feature-routes.ts
│ │ └── feature.test.ts
│ ├── ... other folders ...
│ ├── routes.ts
│ └── server.ts
├── tsconfig.json
└── vitest.config.tsExpress folgt im Allgemeinen dem MVC-Muster.
Wenn Ihre App ein reines REST-API-Backend ist, wie dieses Tutorial zeigt, benötigen Sie keine View-Schicht in Ihrer Express-App.
Im API-Design definiert eine Route den Pfad und die HTTP-Methode (z. B. GET, POST), die ein Client verwendet, um auf eine bestimmte Ressource oder Funktionalität zuzugreifen. Ein Endpunkt bezieht sich auf die spezifische URL, unter der diese Ressource oder Funktionalität zugänglich ist. Der Controller enthält die Logik, die ausgeführt wird, wenn eine Route aufgerufen wird. Zusammenfassend lässt sich sagen, dass Routen und Endpunkte festlegen, wie und wo Clients auf Ressourcen zugreifen können, während Controller definieren, was passiert, wenn diese Routen aufgerufen werden.
Routen und Endpunkte werden in informellen Diskussionen oft synonym verwendet, aber technisch gesehen:
Betrachten Sie die folgende HTTP-Anfrage:
GET https://api.example.com/users/123Sie können es wie folgt aufschlüsseln:
/users/:iduserController Objekt und/oder der user-controller.ts Datei.Bei langen Routen wie /api/v1/organizations/:slug/members/:id, könnte ein Endpunkt so aussehen:
GET https://api.example.com/api/v1/organizations/acme/members/123Jeder Teil der Route hat einen spezifischen Namen:
/api - Basispfad oder API-Namensraum./v1 - API-Versionssegment./organizations - Primärer Ressourcenpfad./:slug - Routenparameter für die Organisations-ID./members - Verschachtelter Ressourcenpfad.Ihre App ist nun korrekt eingerichtet, und Sie sind bereit, Ihren ersten Test für Ihr erstes Feature zu schreiben.
Zuerst erstellen Sie einen einfachen Health-Check-Endpunkt. Ein Health-Check-Endpunkt ermöglicht es Überwachungssystemen wie Load Balancern oder Orchestratoren wie Kubernetes, festzustellen, ob Ihre Anwendung korrekt läuft und bereit ist, Traffic zu verarbeiten. Er hilft dabei, Probleme wie abgestürzte Prozesse, nicht reagierende Dienste oder fehlgeschlagene Abhängigkeiten zu erkennen. Diese Orchestratoren können Ihre App dann befähigen, sich automatisch von Fehlern zu erholen und neue Versionen intelligent auszurollen.
Erstellen Sie einen Test für einen Health-Check-Endpunkt.
// src/features/health-check/health-check.test.ts
import request from 'supertest';
import { describe, expect, test } from 'vitest';
import { buildApp } from '~/app.js';
describe('/api/v1/health-check', () => {
test('given: a GET request, should: return a 200 with a message, timestamp and uptime', async () => {
const app = buildApp();
const actual = await request(app).get('/api/v1/health-check').expect(200);
const expected = {
message: 'OK',
timestamp: expect.any(Number),
uptime: expect.any(Number),
};
expect(actual.body).toEqual(expected);
});
});Ihr Test sendet einfach eine GET-Anfrage an den /api/v1/health-check Endpunkt und prüft, ob die Antwort den Statuscode 200 hat, zusammen mit einer Nachricht, einem Zeitstempel und der Uptime.
Führen Sie den Test aus und beobachten Sie, wie er fehlschlägt.
npm test
❯ src/features/health-check/health-check.test.ts (1 test | 1 failed) 13ms
× /api/v1/health-check > given: a GET request, should: return a 200 with a message, timestamp and uptime 12ms
→ expected 200 "OK", got 404 "Not Found"
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Test Files 1 failed (1)
Tests 1 failed (1)
Start at 13:09:40
Duration 78ms
FAIL Tests failed. Watching for file changes...
press h to show help, press q to quitDer Test schlägt mit einem 404 Not Found-Fehler fehl. Das liegt daran, dass wir noch keine Routen definiert haben.
Vitest führt standardmäßig ein Watch-Skript aus, daher sollten Sie npm test laufen lassen, während Sie an Ihrem Code arbeiten.
Lassen Sie uns den Test bestehen. Beginnen Sie damit, einen Controller mit einem Handler für den Health-Check-Endpunkt hinzuzufügen.
// src/features/health-check/health-check-controller.ts
import type { NextFunction, Request, Response } from 'express';
export async function healthCheckHandler(
request: Request,
response: Response,
next: NextFunction,
) {
try {
const body = {
message: 'OK',
timestamp: Date.now(),
uptime: process.uptime(),
};
response.json(body);
} catch (error) {
next(error);
}
}Erstellen Sie einen einfachen Body, der eine Nachricht, einen Zeitstempel und die Betriebszeit enthält, und senden Sie ihn dann als JSON-Antwort, die standardmäßig einen 200er-Statuscode hat.
Sie verwenden einen try-catch-Block, um Fehler zu behandeln und rufen die `next`-Funktion auf, um den Fehler an eine beliebige Fehlerbehandlungs-Middleware weiterzuleiten. In diesem Tutorial haben Sie keine Fehlerbehandlungs-Middleware erstellt, daher verwendet Express standardmäßig seinen integrierten Fehler-Handler. Dieser Standard-Handler protokolliert den Fehler in der Konsole und sendet eine einfache Fehlerantwort an den Client zurück, zum Beispiel einen 500er-Statuscode mit einer Meldung wie Interner Serverfehler.
Jede Funktion erhält mindestens einen Controller und einen Router. Erstellen Sie als Nächstes die Router-Datei.
// src/features/health-check/health-check-routes.ts
import { Router } from 'express';
import { healthCheckHandler } from './health-check-controller.js';
const router = Router();
router.get('/', healthCheckHandler);
export { router as healthCheckRoutes };Importieren Sie den healthCheckHandler aus dem Controller. Richten Sie dann eine GET-Route unter dem Root-Pfad / ein, die den healthCheckHandler verwendet, und exportieren Sie den konfigurierten Router als healthCheckRoutes.
Erstellen Sie nun eine Hauptdatei für alle Routen.
// src/routes.ts
import { Router } from 'express';
import { healthCheckRoutes } from '~/features/health-check/health-check-routes.js';
export const apiV1Router = Router();
apiV1Router.use('/health-check', healthCheckRoutes);Hier richten Sie den Basis-Routenpfad ein /health-check für die Health-Check-Routen, wobei /health-check der primäre Ressourcenpfad ist.
Zusätzlich können Sie, wenn Sie APIs migrieren, verschiedene Versionen (z. B. apiV2Router) der API in der routes.ts Datei definieren.
Fügen Sie die Routen Ihrer App in der src/app.ts Datei hinzu.
// src/app.ts
import type { Express } from 'express';
import express from 'express';
import { apiV1Router } from './routes.js';
export function buildApp(): Express {
const app = express();
// Middleware for JSON parsing.
app.use(express.json());
// Group routes under /api/v1.
app.use('/api/v1', apiV1Router);
return app;
}Hier legen Sie den Basispfad und das API-Versionssegment für den Router fest.
Ihr Test wird nun erfolgreich sein.
✓ src/features/health-check/health-check.test.ts (1 test) 10ms
✓ /api/v1/health-check > given: a GET request, should: return a 200 with a message, timestamp and uptime
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 14:01:14
Duration 99ms
PASS Waiting for file changes...
press h to show help, press q to quit
asyncHandlerEigentlich ist das Muster, das Sie bereits kennen, bei dem Sie next in Ihren Handlern verwenden, ziemlich lästig. Es zwingt Sie, 3 Argumente zu verwenden, fügt eine weitere Einrückungsebene hinzu und macht den Code weniger lesbar und boilerplate-lastiger.
Erstellen wir also eine Hilfsfunktion, die Ihren Handler in einen try-catch-Block einschließt und aufruft next mit dem Fehler, falls er auftritt.
// src/utils/async-handler.ts
import type { NextFunction, Request, Response } from 'express';
import type { ParamsDictionary } from 'express-serve-static-core';
import type { ParsedQs } from 'qs';
/**
* A helper that wraps an async route handler (without `next`) so that any errors are automatically
* passed to `next()`. This avoids having to include try/catch blocks in every async handler.
*
* @param fn - An asynchronous Express request handler that returns a Promise.
* @returns A standard Express request handler.
*/
export function asyncHandler<
P = ParamsDictionary,
ResponseBody = unknown,
RequestBody = unknown,
RequestQuery = ParsedQs,
LocalsObject extends Record<string, unknown> = Record<string, unknown>,
>(
function_: (
request: Request<P, ResponseBody, RequestBody, RequestQuery, LocalsObject>,
response: Response<ResponseBody, LocalsObject>,
) => Promise<void>,
): (
request: Request<P, ResponseBody, RequestBody, RequestQuery, LocalsObject>,
response: Response<ResponseBody, LocalsObject>,
next: NextFunction,
) => Promise<void> {
return async function (
request: Request<P, ResponseBody, RequestBody, RequestQuery, LocalsObject>,
response: Response<ResponseBody, LocalsObject>,
next: NextFunction,
): Promise<void> {
try {
await function_(request, response);
} catch (error) {
next(error);
}
};
}Diese Funktion besteht aus vielen Codezeilen, aber das ist nur, um TypeScript glücklich zu machen. Im Grunde läuft es auf Folgendes hinaus:
// temp-async-handler.js
function asyncHandler(fn) {
return async function (request, response, next) {
try {
await fn(request, response);
} catch (error) {
next(error);
}
};
}Sie rufen die asyncHandler Funktion mit Ihrem Handler auf, und sie gibt einen neuen Handler zurück, den Sie in Ihrem Router verwenden können. Sie schließt Ihren ursprünglichen Handler in einen try-catch-Block ein und ruft next mit dem Fehler, falls er auftritt.
Dadurch können Sie Ihren Handler vereinfachen, indem Sie auf den try-catch-Block und die next Funktion verzichten.
// src/features/health-check/health-check-controller.ts
import type { Request, Response } from 'express';
export async function healthCheckHandler(request: Request, response: Response) {
const body = {
message: 'OK',
timestamp: Date.now(),
uptime: process.uptime(),
};
response.json(body);
}Jetzt können Sie die asyncHandler in Ihrer health-check-routes.ts Datei verwenden.
// src/features/health-check/health-check-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import { healthCheckHandler } from './health-check-controller.js';
const router = Router();
router.get('/', asyncHandler(healthCheckHandler));
export { router as healthCheckRoutes };Zukünftig werden Sie die asyncHandler Hilfsfunktion für alle Ihre Handler verwenden.
In diesem Tutorial verwenden Sie Prisma mit PostgreSQL. Installieren Sie die Postgres App , um eine lokale PostgreSQL-Datenbank zu erstellen.
Installieren Sie anschließend Prisma, den Prisma-Client und die CUID2-Bibliothek.
npm i -D prisma && npm i @prisma/client @paralleldrive/cuid2Initialisieren Sie Prisma.
npx prisma initDadurch wird eine .env -Datei und eine prisma/schema.prisma -Datei generiert. Stellen Sie sicher, dass die DATABASE_URL in Ihrer .env -Datei die korrekte Datenbank-URL und die Anmeldeinformationen enthält.
Fügen Sie die folgenden Skripte zu Ihrer package.json -Datei hinzu.
// package.json
"prisma:deploy": "npx prisma migrate deploy && npx prisma generate",
"prisma:migrate": "npx prisma migrate dev --name",
"prisma:push": "npx prisma db push && npx prisma generate",
"prisma:seed": "tsx ./prisma/seed.ts",
"prisma:setup": "prisma generate && prisma migrate deploy && prisma db push",
"prisma:studio": "npx prisma studio",
"prisma:wipe": "npx prisma migrate reset --force && npx prisma db push",Das einzige wichtige Skript für dieses Tutorial ist prisma:setup. Es wird die Datenbank erstellen und den Prisma-Client generieren. Sie werden es sehr bald ausführen.
Eine vollständige Erklärung all dieser Skripte finden Sie in meinem Artikel „So richten Sie Next.js 15 für die Produktion im Jahr 2025 ein“.
Fügen Sie nun ein UserProfile -Modell zu Ihrer prisma/schema.prisma -Datei hinzu.
// prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model UserProfile {
id String @id @default(cuid(2))
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @default("")
hashedPassword String
}Führen Sie npm run prisma:setup aus, um die Datenbank zu erstellen und den Prisma-Client zu generieren.
Erstellen Sie eine database.ts -Datei, um eine Verbindung zur Datenbank herzustellen.
// src/database.ts
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma = globalThis.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}Prisma ist nun einsatzbereit, aber es fehlen noch einige Dinge, bevor wir an den nächsten Funktionen arbeiten können.
Bei der Arbeit mit externen APIs, Datenbanken oder anderen Diensten ist es ratsam, eine Fassade zu erstellen. Eine Fassade ist ein Wrapper um den Dienst, der bietet eine vereinfachte Schnittstelle zu einem komplexen Subsystem.
Fassaden sind aus zwei Gründen nützlich:
1. Anbieterunabhängigkeit erhöhen- Fassaden ermöglichen einen schnellen Austausch von Anbietern. Zum Beispiel können Sie mit einer einzigen Änderung von Postgres zu MongoDB wechseln. Sie aktualisieren die Implementierung (= die Struktur) der Fassade, und Ihr Code, der die Fassade verwendet, kann unverändert bleiben.
2. Code vereinfachen- Fassaden passen Ihre API an Ihre Bedürfnisse an. Sie reduzieren die Menge an Code, die Sie schreiben müssen, da Sie nur die Argumente angeben und genau die Rückgabewerte erhalten, die für Sie relevant sind. Und sie machen Ihren Code durch aussagekräftige Namen klarer.
Erstellen Sie eine Datei für Ihre Fassaden.
// src/features/user-profile/user-profile-model.ts
import type { Prisma, UserProfile } from '@prisma/client';
import { prisma } from '~/database.js';
/* CREATE */
/**
* Saves a user profile to the database.
*
* @param userProfile The user profile to save.
* @returns The saved user profile.
*/
export async function saveUserProfileToDatabase(
userProfile: Prisma.UserProfileCreateInput,
) {
return prisma.userProfile.create({ data: userProfile });
}
/* READ */
/**
* Retrieves a user profile by its id.
*
* @param id The id of the user profile.
* @returns The user profile or null.
*/
export async function retrieveUserProfileFromDatabaseById(
id: UserProfile['id'],
) {
return prisma.userProfile.findUnique({ where: { id } });
}
/**
* Retrieves a user profile by its email.
*
* @param email The email of the user profile.
* @returns The user profile or null.
*/
export async function retrieveUserProfileFromDatabaseByEmail(
email: UserProfile['email'],
) {
return prisma.userProfile.findUnique({ where: { email } });
}
/**
* Retrieves many user profiles.
*
* @param page The page number (starting at 1).
* @param pageSize The number of profiles per page.
* @returns A list of user profiles.
*/
export async function retrieveManyUserProfilesFromDatabase({
page = 0,
pageSize = 10,
}: {
page?: number;
pageSize?: number;
}) {
const skip = (page - 1) * pageSize;
return prisma.userProfile.findMany({
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
});
}
/* UPDATE */
/**
* Updates a user profile by its id.
*
* @param id The id of the user profile.
* @param data The new data for the profile.
* @returns The updated user profile.
*/
export async function updateUserProfileInDatabaseById({
id,
data,
}: {
id: UserProfile['id'];
data: Prisma.UserProfileUpdateInput;
}) {
return prisma.userProfile.update({ where: { id }, data });
}
/* DELETE */
/**
* Deletes a user profile by its id.
*
* @param id The id of the user profile.
* @returns The deleted user profile.
*/
export async function deleteUserProfileFromDatabaseById(id: UserProfile['id']) {
return prisma.userProfile.delete({ where: { id } });
}Typischerweise erstellen Sie einen vollständigen Satz von CRUD-Operationen (Create, Read, Update, Delete) für jedes Ihrer Modelle in Ihrer Modelldatei.
Für Erstellung, exportiert sie eine Funktion, die ein Benutzerprofil als Eingabe entgegennimmt und es mithilfe der `create`-Methode von Prisma in der Datenbank speichert. Dies demonstriert das Fassadenmuster in Aktion: Prisma, ein komplexes Subsystem, bietet eine umfangreiche API mit vielen Funktionen, aber Ihre Erstellungsfassade vereinfacht dies auf das Speichern eines einzelnen Benutzerprofils. Suchen Sie nach demselben Mechanismus in den folgenden Funktionen.
Im Bereich Lesen gibt es Funktionen, um ein Benutzerprofil entweder anhand seiner eindeutigen ID oder E-Mail-Adresse abzurufen, sowie eine Funktion, um mehrere Profile mit Paginierung abzurufen, wobei die Ergebnisse nach Erstellungsdatum in absteigender Reihenfolge sortiert werden (= die neuesten Profile zuerst).
Die Aktualisierung -Operation wird von einer Funktion gehandhabt, die eine ID und einen Satz neuer Daten entgegennimmt und das entsprechende Benutzerprofil in der Datenbank aktualisiert.
Schließlich entfernt die delete Funktion ein Benutzerprofil anhand seiner ID.
Sie werden diese Fassaden später sowohl in Ihren Tests als auch in Ihrem Anwendungscode verwenden.
Eine Factory-Funktion ist einfach eine Funktion, die ein Objekt zurückgibt. Dieses Objekt repräsentiert typischerweise eine sinnvolle Einheit in Ihrer Anwendung, wie z.B. einen Datenbankeintrag, eine benutzerdefinierte Datenstruktur oder ein Objekt in der objektorientierten Programmierung. Später in diesem Tutorial werden Sie Factory-Funktionen verwenden, um Platzhalterdaten für Ihre Tests zu erstellen.
Erstellen Sie zunächst einen generischen Factory Typ, den Sie in Ihrer gesamten Codebasis für jede Factory wiederverwenden werden.
// src/utils/types.ts
/**
* Arbitrary factory function for object of shape `Shape`.
*/
export type Factory<Shape> = (object?: Partial<Shape>) => Shape;Wenn Sie diesen Typ verwenden, können Sie die Standardwerte eines Objekts überschreiben und gleichzeitig sicherstellen, dass alle erforderlichen Eigenschaften vorhanden sind.
Das einzige Modell in Ihrer Datenbank ist das Benutzerprofil, also erstellen Sie eine Factory-Funktion dafür.
// src/features/user-profile/user-profile-factories.ts
import { faker } from '@faker-js/faker';
import { createId } from '@paralleldrive/cuid2';
import type { UserProfile } from '@prisma/client';
import type { Factory } from '~/utils/types.js';
export const createPopulatedUserProfile: Factory<UserProfile> = ({
id = createId(),
email = faker.internet.email(),
name = faker.person.fullName(),
updatedAt = faker.date.recent({ days: 10 }),
createdAt = faker.date.past({ years: 3, refDate: updatedAt }),
hashedPassword = faker.string.uuid(),
} = {}) => ({ id, email, name, createdAt, updatedAt, hashedPassword });Diese Factory-Funktion ermöglicht es Ihnen, schnell Benutzerprofile mit Dummy-Daten zu erstellen.
Sie werden Zod verwenden, um Abfragen und Bodies zu validieren. Normalerweise würden Sie express-validator dafür verwenden, aber es funktioniert nicht gut mit TypeScript, da Express die Form der Daten nicht inferieren kann. Ich werde dies gleich noch genauer erklären.
Zod installieren.
npm i zodErstellen Sie nun eine src/middleware/validate.ts Datei.
// src/middleware/validate.ts
import type { Request, Response } from 'express';
import type { ZodSchema } from 'zod';
import { ZodError } from 'zod';
export function createValidate(key: 'body' | 'query' | 'params') {
return async function validate<T>(
schema: ZodSchema<T>,
request: Request,
response: Response,
): Promise<T> {
try {
const result = await schema.parseAsync(request[key]);
return result;
} catch (error) {
if (error instanceof ZodError) {
response
.status(400)
.json({ message: 'Bad Request', errors: error.errors });
throw new Error('Validation failed');
}
throw error;
}
};
}
export const validateBody = createValidate('body');
export const validateQuery = createValidate('query');
export const validateParams = createValidate('params');Darin erstellen Sie eine createValidate Funktion, die curried ist, einen Schlüssel entgegennimmt und eine Funktion zurückgibt, die den Request-Body, die Query oder die Params mithilfe der parseAsync Methode des Zod-Schemas validiert.
Falls Sie sich fragen, worin der Unterschied zwischen den body, queryund params Keys besteht, hier eine kurze Erklärung:
body: Enthält die im Request-Payload gesendeten Daten (häufig verwendet bei POST, PUT usw.) und wird typischerweise über Middleware wie body-parser.Query: Enthält Schlüssel-Wert-Paare aus dem Abfrage-String der URL (dem Teil nach ?), oft für Filterung oder Paginierung verwendet.Parameter: Besteht aus Routenparametern, die im URL-Pfad definiert sind (z. B. id in /users/:id), die verwendet werden, um bestimmte Segmente der URL zu erfassen.Anschließend erstellen Sie drei Exporte, die den Body, die Query und die Parameter validieren.
Erinnern Sie sich noch, als ich sagte, dass express-validator nicht gut mit TypeScript zusammenarbeitet? express-validator wird normalerweise so verwendet:
// temp-express-validator-example.ts
import express from 'express';
import { query } from 'express-validator';
const app = express();
app.use(express.json());
app.get('/hello', query('person').notEmpty(), (request, response) => {
response.send(`Hello, ${request.query.person}!`);
});
app.listen(3000);In diesem Code-Snippet weiß TypeScript NICHT, dass request.query.person ein String ist, weil express-validator läuft zur Laufzeit, während das Typsystem von TypeScript nur Kenntnis von den statischen Typdefinitionen hat, die von Express bereitgestellt werden.
Aber mit Ihrer benutzerdefinierten validateQuery Funktion, weiß TypeScript, dass der person Abfrageparameter ein String ist.
So können Sie dies in Ihrem Code nutzen:
// temp-validate-query-example.ts
import express from 'express';
import { z } from 'zod';
import { validateQuery } from '../middleware/validate';
const app = express();
// Define a Zod schema for the query parameters.
const helloQuerySchema = z.object({
person: z.string().min(1, { message: 'person is required' }),
});
app.get('/hello', async (request, response, next) => {
try {
// Validate and parse the query using our custom validator.
const query = await validateQuery(helloQuerySchema, request, response);
// TypeScript now knows that query.person is a string.
response.send(`Hello, ${query.person}!`);
} catch (error) {
// Handle errors appropriately (validation errors are already sent to the
// client).
next(error);
}
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});Sie definieren ein Zod-Schema namens helloQuerySchema das einen person Eigenschaft als nicht-leeren String erwartet. Ihre validateQuery Funktion verwendet dieses Schema, um das Query-Objekt der Anfrage zu parsen. Wenn die Validierung erfolgreich ist, gibt sie ein Objekt mit den korrekten Typen zurück. Schlägt sie fehl, wird automatisch eine 400-Antwort gesendet.
Und dank Zods statischer Typinferenz versteht TypeScript nun, dass query.person ein String ist, was sowohl die Entwicklererfahrung als auch die Typsicherheit verbessert.
Dieses Muster kann ähnlich auf den Anfragetext mittels validateBody oder auf URL-Parameter mittels Parameter validieren.
Eine weitere Sache, die Ihr Server können muss, ist das Lesen von Cookies. Standardmäßig kann Express Cookies in Antworten setzen, aber nicht aus Anfragen lesen.
Dafür können Sie die cookie-parser Middleware verwenden, also installieren Sie sie.
npm i cookie-parser && npm i -D @types/cookie-parserFügen Sie die Cookie-Parser-Middleware Ihrer App hinzu.
// src/app.ts
import cookieParser from 'cookie-parser';
import type { Express } from 'express';
import express from 'express';
import { apiV1Router } from './routes.js';
export function buildApp(): Express {
const app = express();
// Middleware for JSON parsing.
app.use(express.json());
app.use(cookieParser());
// Group routes under /api/v1.
app.use('/api/v1', apiV1Router);
return app;
}Jede Anfrage wird nun ein request.cookies Objekt haben, das die vom Client gesendeten Cookies enthält.
Die meisten Anwendungen benötigen eine Form der Authentifizierung. In diesem Tutorial werden Sie JWT-Tokens in Cookies verwenden, um Anfragen zu authentifizieren. Und Benutzer werden eine klassische E-Mail- und Passwort-Kombination zur Authentifizierung verwenden.
Es ist jedoch wichtig zu beachten, dass Passwörter veraltet sind. Hören Sie auf, Passwörter zu sammeln oder zu speichern. Passwörter sind schwach, weil sie kopiert, gestohlen oder durch Brute-Force-Angriffe geknackt werden können. Wählen Sie Passkeys für einen starken Login und verwenden Sie E-Mail-OTPs nur als Backup.
Der einzige Grund, warum ich dir die Passwort-Authentifizierung beibringe, ist, dass viele Apps immer noch Passwörter verwenden. Diese Fähigkeit ist auf dem heutigen Arbeitsmarkt weiterhin wertvoll, selbst wenn es nur darum geht, Risiken zu erkennen und Passwörter durch sichere Passkeys zu ersetzen.
Und alles, was du über den Umgang mit JWT-Tokens und Cookies lernst, wird nützlich sein, egal welche Authentifizierungsmethode du wählst.
Nachdem dieser Hinweis geklärt ist, erstellen wir die Authentifizierungsfunktion.
Die Authentifizierung erfolgt über Cookies. Wenn sich ein Benutzer registriert oder anmeldet, wird ein Cookie in der Antwort gesetzt. Der Browser deines Benutzers sendet dieses Cookie automatisch mit jeder Anfrage an deinen Server. Dein Server kann das Cookie dann lesen und zur Authentifizierung des Benutzers verwenden. Zusätzlich erstellt die `register`-Route den Benutzer und speichert ihn in deiner Datenbank. Die Abmelde-Route ist einfach eine Antwort, die den Browser anweist, das Cookie zu löschen.
Beginne mit der Login -Route und erstelle eine src/features/user-authentication/user-authentication.test.ts -Datei.
// src/features/user-authentication/user-authentication.test.ts
import { createId } from '@paralleldrive/cuid2';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';
import { buildApp } from '~/app.js';
import { createPopulatedUserProfile } from '../user-profile/user-profile-factories.js';
import {
deleteUserProfileFromDatabaseById,
saveUserProfileToDatabase,
} from '../user-profile/user-profile-model.js';
import { hashPassword } from './user-authentication-helpers.js';
async function setup({ password = 'password' }: { password?: string } = {}) {
const app = buildApp();
const userProfile = await saveUserProfileToDatabase(
createPopulatedUserProfile({
hashedPassword: await hashPassword(password),
}),
);
onTestFinished(async () => {
await deleteUserProfileFromDatabaseById(userProfile.id);
});
return { app, userProfile };
}
describe('/api/v1/login', () => {
test('given: valid credentials for an existing user, should: return a 200 and set a JWT cookie', async () => {
const password = createId();
const { app, userProfile } = await setup({ password });
const actual = await request(app)
.post('/api/v1/login')
.send({ email: userProfile.email, password })
.expect(200);
expect(actual.body).toEqual({ message: 'Logged in successfully' });
// Verify that the HTTP-only cookie has been set. It is typed wrongly as a
// string by supertest for some reason, even though it is an array.
const cookies = actual.headers['set-cookie'] as unknown as string[];
expect(cookies).toBeDefined();
expect(cookies.some(cookie => cookie.includes('jwt='))).toEqual(true);
});
test('given: valid credentials for a non-existing user, should: return a 401', async () => {
const { app } = await setup();
const { body: actual } = await request(app)
.post('/api/v1/login')
.send({ email: 'non-existing@test.com', password: 'password' })
.expect(401);
const expected = { message: 'Invalid credentials' };
expect(actual).toEqual(expected);
});
test('given: valid credentials, but wrong password for an existing user, should: return a 401', async () => {
const { app, userProfile } = await setup();
const actual = await request(app)
.post('/api/v1/login')
.send({ email: userProfile.email, password: 'invalid password' })
.expect(401);
expect(actual.body).toEqual({ message: 'Invalid credentials' });
});
test('given: invalid credentials, should: return a 400', async () => {
const { app } = await setup();
const { body: actual } = await request(app)
.post('/api/v1/login')
.send({})
.expect(400);
const expected = {
message: 'Bad Request',
errors: [
{
code: 'invalid_type',
expected: 'string',
message: 'Required',
path: ['email'],
received: 'undefined',
},
{
code: 'invalid_type',
expected: 'string',
message: 'Required',
path: ['password'],
received: 'undefined',
},
],
};
expect(actual).toEqual(expected);
});
});Du beginnst mit der Erstellung einer setup -Funktion, die die App erstellt, ein Benutzerprofil mit einem gehashten Passwort anlegt und es in der Datenbank speichert. Zusätzlich registrierst du einen onTestFinished -Handler, um das Benutzerprofil nach Abschluss der Tests zu löschen.
Deine hashPassword Die Funktion existiert noch nicht, aber Sie werden sie sehr bald erstellen. Generell ist es beim TDD in Ordnung, Funktionen zu verwenden, die noch nicht existieren, da Sie diese ebenfalls rekursiv TDD-en können. Normalerweise erstellen Sie zuerst eine leere Version davon, damit die Imports funktionieren, und implementieren dann das Verhalten.
Danach erstellen Sie einen Test für die /api/v1/login Route.
Sie testen zuerst den „Happy Path“, bei dem der Benutzer existiert und die Anmeldeinformationen gültig sind.
Nun müssen Sie 4 Testfälle behandeln:
Jeder dieser Tests bestätigt den korrekten HTTP-Statuscode und den korrekten Antworttext.
Um die Route und ihre Tests zu implementieren, benötigen Sie nun ein paar Hilfsfunktionen.
Sie benötigen eine Funktion zum Hashen des Passworts, eine weitere zum Vergleichen des angegebenen Passworts mit dem gehashten, eine Funktion zum Generieren eines JWT-Tokens für den Benutzer und eine Funktion zum Setzen des JWT-Cookies. Zusätzlich benötigen Sie eine Möglichkeit zu prüfen, ob der Token gültig ist, und eine Funktion, um den JWT-Token aus den Cookies der Anfrage abzurufen. Lassen Sie uns beide Funktionen gemeinsam TDD-en.
Die hashPassword Funktion und die getIsPasswordValid Funktion sind ein Paar von Funktionen, die nur zusammen Sinn ergeben. Daher möchten Sie sie gemeinsam in ihrem Test verwenden.
// src/features/user-authentication/user-authentication-helpers.test.ts
import { createId } from '@paralleldrive/cuid2';
import { describe, expect, test } from 'vitest';
import {
getIsPasswordValid,
hashPassword,
} from './user-authentication-helpers.js';
describe('getIsPasswordValid() & hashPassword()', () => {
test('given: a password, should: return a hashed password', async () => {
const password = createId();
const hashedPassword = await hashPassword(password);
const actual = await getIsPasswordValid(password, hashedPassword);
const expected = true;
expect(actual).toEqual(expected);
});
});In Ihrem Test verwenden Sie hashPassword um das Passwort zu hashen und dann zu verwenden getIsPasswordValid um zu überprüfen, ob das Passwort gültig ist.
Dies ist ein klassischer Fall, in dem Funktionen zusammen verwendet werden, die man immer zusammen erwartet. Ein weiterer solcher Fall ist, wenn Sie Tests für Ihre Action Creators zusammen mit den entsprechenden Selektoren in einer Redux-Anwendung schreiben.
Sie können Passwörter mit der bcrypt Bibliothek hashen. Installieren Sie sie.
npm i bcrypt && npm i -D @types/bcryptImplementieren Sie nun beide Funktionen.
// src/features/user-authentication/user-authentication-helpers.ts
import bcrypt from 'bcrypt';
/**
* Hash a password.
*
* @param password The password to hash.
* @returns The hashed password.
*/
export async function hashPassword(password: string) {
return await bcrypt.hash(password, 10);
}
/**
* Compare a password with a hashed password.
*
* @param password The password to compare.
* @param hashedPassword The hashed password to compare against.
* @returns True if the password is valid, false otherwise.
*/
export async function getIsPasswordValid(
password: string,
hashedPassword: string,
) {
return await bcrypt.compare(password, hashedPassword);
}Sie importieren bcrypt, um Passwörter sicher zu hashen und zu vergleichen. In der hashPassword Funktion hashen Sie ein Klartext-Passwort mit 10 Salt-Runden.
In der getIsPasswordValid Funktion vergleichen Sie ein gegebenes Klartext-Passwort mit seiner gehashten Version, um zu überprüfen, ob sie übereinstimmen.
Ihre Tests sollten nun bestanden werden, und Sie können einen Test für eine neue Funktion hinzufügen, die einen JWT-Token generiert.
// src/features/user-authentication/user-authentication-helpers.test.js
// ... other imports ...
import {
generateJwtToken,
getIsPasswordValid,
hashPassword,
} from './user-authentication-helpers.js';
// ... the existing tests ...
describe('generateJwtToken()', () => {
test('given: a user profile, should: return a JWT token', () => {
const userProfile = {
id: 'ozlnvq593weqj51j5p69adul',
email: 'Jamarcus.Haag44@hotmail.com',
name: 'Dr. Philip Lindgren',
createdAt: new Date('2022-09-25T20:03:54.119Z'),
updatedAt: new Date('2025-01-29T11:25:38.342Z'),
hashedPassword: 'b6d93ffb-8093-4940-bd1f-c9e8020851e4',
};
const jwtToken = generateJwtToken(userProfile);
const actual = jwtToken.startsWith('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9');
const expected = true;
expect(actual).toEqual(expected);
});
});Im Test für generateJwtToken()erstellen Sie ein Beispiel-Benutzerprofil, generieren daraus einen Token und überprüfen dann, ob der Token mit der erwarteten JWT-Header-Zeichenfolge beginnt. Diese Header-Zeichenfolge könnte je nach Ihrer Umgebung und JWT_SECRET, den Sie als Nächstes festlegen werden, wenn Sie die Funktion implementieren.
Um die Funktion zu implementieren, müssen Sie einige zusätzliche Pakete installieren.
npm install dotenv jsonwebtoken && npm i -D @types/jsonwebtokenImplementieren Sie nun die Funktion.
// src/features/user-authentication/user-authentication-helpers.ts
import type { UserProfile } from '@prisma/client';
import bcrypt from 'bcrypt';
import dotenv from 'dotenv';
import jwt from 'jsonwebtoken';
dotenv.config();
// ... existing functions ...
/**
* Generate a JWT token. Make sure to define process.env.JWT_SECRET in your
* environment.
*
* @param userProfile The user profile to generate the token for.
* @returns The generated JWT token.
*/
export function generateJwtToken(userProfile: UserProfile) {
const tokenPayload: TokenPayload = {
id: userProfile.id,
email: userProfile.email,
};
return jwt.sign(tokenPayload, process.env.JWT_SECRET as string, {
expiresIn: 60 * 60 * 24 * 365, // 1 year
});
}Sie importieren dotenv um Umgebungsvariablen aus einer .env Datei in process.env.
Erstellen Sie also eine .env Datei im Stammverzeichnis Ihres Projekts und fügen Sie die JWT_SECRET Variable hinzu.
env title=".env"
JWT_SECRET=your-jwt-secretDie generateJwtToken Funktion nimmt ein Benutzerprofil entgegen, extrahiert die id und E-Mail, und erstellt dann einen JWT-Token unter Verwendung dieser Details. Es signiert den Token mit dem Geheimnis aus Ihrer Umgebung und setzt das Ablaufdatum des Tokens auf ein Jahr.
Jetzt sollte Ihr Test erfolgreich sein.
Die letzte Funktion, die Sie erstellen müssen, bevor Sie die Route implementieren können, ist eine Funktion zum Setzen des JWT-Cookies. Diese Funktion benötigt keine Tests, da Sie für einen Unit-Test das Express- Response -Objekt mocken müssten und Sie eher den Mock als die eigentliche Funktion testen würden. Stattdessen wird diese Funktion implizit in Ihren Integrationstests getestet.
// src/features/user-authentication/user-authentication-helpers.ts
// ... other imports ...
import type { Response } from 'express';
import jwt from 'jsonwebtoken';
// ... existing functions ...
export const JWT_COOKIE_NAME = 'jwt';
/**
* Set the JWT cookie.
*
* @param response The response object to set the cookie on.
* @param token The JWT token to set.
*/
export function setJwtCookie(response: Response, token: string) {
response.cookie(JWT_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // use secure cookies in production
sameSite: 'strict',
});
}Sie erstellen eine Funktion namens setJwtCookie , die ein Express-Response-Objekt und einen JWT-Token entgegennimmt und dann einen Cookie mit diesem Token in der Response setzt. Die Cookie-Einstellungen umfassen:
Jetzt können Sie die /api/v1/login -Route implementieren.
// src/features/user-authentication/user-authentication-controller.ts
import type { Request, Response } from 'express';
import { z } from 'zod';
import { validateBody } from '~/middleware/validate.js';
import {
retrieveUserProfileFromDatabaseByEmail,
} from '../user-profile/user-profile-model.js';
import {
generateJwtToken,
getIsPasswordValid,
setJwtCookie,
} from './user-authentication-helpers.js';
export async function login(request: Request, response: Response) {
// Validate the request body to contain a valid email and a password of
// minimum 8 characters.
const body = await validateBody(
z.object({
email: z.string().email(),
password: z.string().min(8),
}),
request,
response,
);
// Attempt to find the user in the database by email.
const user = await retrieveUserProfileFromDatabaseByEmail(body.email);
if (user) {
const isPasswordValid = await getIsPasswordValid(
body.password,
user.hashedPassword,
);
if (isPasswordValid) {
// Generate a JWT token, set it in an HTTP-only cookie and return a
// 200 status and a message.
const token = generateJwtToken(user);
setJwtCookie(response, token);
response.status(200).json({ message: 'Logged in successfully' });
} else {
// If the password is invalid, return a 401 status and a message.
response.status(401).json({ message: 'Invalid credentials' });
}
} else {
// If user not found, return an Unauthorized error.
response.status(401).json({ message: 'Invalid credentials' });
}
}Sie definieren eine asynchrone login Funktion zur Handhabung der Benutzerauthentifizierung. Die Funktion beginnt mit der Validierung des eingehenden Anfragetextes mithilfe des validateBody Middleware in Kombination mit einem Zod-Schema. Dieses Schema stellt sicher, dass die Anfrage eine gültige E-Mail-Adresse und ein Passwort mit mindestens 8 Zeichen enthält.
Nach der Validierung der Eingabe versucht die Funktion, den Benutzer anhand der angegebenen E-Mail-Adresse aus der Datenbank abzurufen, indem sie retrieveUserProfileFromDatabaseByEmail. Wird ein Benutzer gefunden, überprüft die Funktion anschließend das Passwort, indem sie das angegebene Passwort mit dem gespeicherten gehashten Passwort des Benutzers vergleicht, unter Verwendung der getIsPasswordValid Funktion.
Wird das Passwort validiert, generiert die Funktion ein JWT-Token über generateJwtToken, setzt dieses Token als HTTP-only-Cookie in der Antwort mithilfe von setJwtCookie, und sendet schließlich eine 200er-Statusantwort mit einer Erfolgsmeldung. Wird der Benutzer nicht gefunden oder schlägt die Passwortvalidierung fehl, gibt die Funktion einen 401er-Status mit der Meldung „Ungültige Anmeldeinformationen“ zurück.
Ihre Tests schlagen immer noch fehl, weil Sie die Route mit dem Router verbinden müssen.
// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import { login } from './user-authentication-controller.js';
const router = Router();
router.post('/login', asyncHandler(login));
export { router as userAuthenticationRoutes };Dieser Router muss auch in Ihrem apiV1Router.
// src/routes.ts
import { Router } from 'express';
import { healthCheckRoutes } from '~/features/health-check/health-check-routes.js';
import { userAuthenticationRoutes } from '~/features/user-authentication/user-authentication-routes.js';
export const apiV1Router = Router();
apiV1Router.use('/health-check', healthCheckRoutes);
apiV1Router.use(userAuthenticationRoutes);Beachten Sie, dass Sie KEIN Segment (z. B. /authentication) für die Benutzerauthentifizierungsrouten hinzufügen. Dies liegt daran, dass Sie diese Routen auf der Stammebene Ihrer API über /login, /register, und /logout.
Jetzt sollten Ihre Tests erfolgreich sein.
Normalerweise würden Sie zuerst die Registrierungsroute implementieren, aber die Login-Route ist einfacher, deshalb haben Sie diese zuerst gesehen.
Fügen Sie Tests für die Registrierungsroute hinzu.
// src/features/user-authentication/user-authentication.test.ts
describe('/api/v1/register', () => {
test('given: valid registration data, should: create a user and return a 201', async () => {
const app = buildApp();
const email = 'test@example.com';
const password = 'password123';
const { body: actual } = await request(app)
.post('/api/v1/register')
.send({ email, password })
.expect(201);
expect(actual).toEqual({ message: 'User registered successfully' });
// Verify that the user was created in the database
const createdUser = await retrieveUserProfileFromDatabaseByEmail(email);
expect(createdUser).toBeDefined();
expect(createdUser?.email).toEqual(email);
// Clean up
if (createdUser) {
await deleteUserProfileFromDatabaseById(createdUser.id);
}
});
test('given: an email that already exists, should: return a 409', async () => {
const password = createId();
const { app, userProfile } = await setup({ password });
const { body: actual } = await request(app)
.post('/api/v1/register')
.send({ email: userProfile.email, password: 'newpassword123' })
.expect(409);
expect(actual).toEqual({ message: 'User already exists' });
});
test('given: invalid registration data, should: return a 400', async () => {
const app = buildApp();
const { body: actual } = await request(app)
.post('/api/v1/register')
.send({})
.expect(400);
expect(actual).toEqual({
message: 'Bad Request',
errors: [
{
code: 'invalid_type',
expected: 'string',
message: 'Required',
path: ['email'],
received: 'undefined',
},
{
code: 'invalid_type',
expected: 'string',
message: 'Required',
path: ['password'],
received: 'undefined',
},
],
});
});
});Der erste Test prüft, ob der Endpunkt bei der Angabe gültiger Registrierungsdaten – einer neuen E-Mail-Adresse und eines Passworts – einen neuen Benutzer erstellt und einen 201-Status mit einer Erfolgsmeldung zurückgibt.
Der zweite Test überprüft, ob der Versuch, ein Konto mit einer E-Mail-Adresse eines bereits existierenden Benutzers zu registrieren, einen 409-Status mit einer Fehlermeldung zurückgibt, was doppelte Konten verhindert.
Und der dritte Test stellt sicher, dass der Endpunkt einen 400-Statuscode zurückgibt und ungültige Registrierungsdaten verarbeitet.
Sie haben bereits alles, was Sie zur Implementierung der Registrierungsroute benötigen.
// src/features/user-authentication/user-authentication-controller.ts
// ... other imports ...
import {
retrieveUserProfileFromDatabaseByEmail,
saveUserProfileToDatabase,
} from '../user-profile/user-profile-model.js';
import {
generateJwtToken,
getIsPasswordValid,
hashPassword,
setJwtCookie,
} from './user-authentication-helpers.js';
// ... other handlers ...
export async function register(request: Request, response: Response) {
// Validate the request body to contain a valid email and a password of
// minimum 8 characters.
const body = await validateBody(
z.object({
email: z.string().email(),
password: z.string().min(8),
}),
request,
response,
);
// Check if a user with this email already exists.
const existingUser = await retrieveUserProfileFromDatabaseByEmail(body.email);
if (existingUser) {
response.status(409).json({ message: 'User already exists' });
} else {
// Hash the password and create the user profile.
const hashedPassword = await hashPassword(body.password);
const user = await saveUserProfileToDatabase({
email: body.email,
hashedPassword,
});
const token = generateJwtToken(user);
setJwtCookie(response, token);
response.status(201).json({ message: 'User registered successfully' });
}
}Sie validieren den Benutzernamen und das Passwort wie gewohnt und prüfen, ob der Benutzer bereits existiert, um Duplikate zu vermeiden.
Falls er nicht existiert, hashen Sie das Passwort und erstellen das Benutzerprofil. Anschließend generieren Sie ein JWT-Token, setzen es als HTTP-only-Cookie in der Antwort und senden einen 201-Status mit einer Erfolgsmeldung.
Beachten Sie, dass Sie KEINEN 400-Fehler explizit auslösen. Dies liegt daran, dass die validateBody -Middleware bereits einen 400-Fehler auslöst, wenn der Anforderungs-Body ungültig ist.
// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import { login, register } from './user-authentication-controller.js';
const router = Router();
router.post('/login', asyncHandler(login));
router.post('/register', asyncHandler(register));
export { router as userAuthenticationRoutes };Nachdem Sie den Handler in Ihrer Routen-Datei eingebunden haben, werden Ihre Tests für die Registrierungslogik erfolgreich sein.
Für die Abmeldefunktion benötigen Sie nur einen Test, da die Route lediglich den Browser anweisen muss, das JWT-Cookie zu löschen.
// src/features/user-authentication/user-authentication.test.ts
describe('/api/v1/logout', () => {
test('given: any POST request, should: clear the JWT cookie and return a 200', async () => {
const { app } = await setup();
const response = await request(app).post('/api/v1/logout').expect(200);
expect(response.body).toEqual({ message: 'Logged out successfully' });
// Verify that the cookie is cleared
const cookies = response.headers['set-cookie'] as unknown as string[];
expect(cookies).toBeDefined();
expect(cookies).toEqual([
'jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Strict',
]);
});
});In diesem Test erwarten Sie, wenn Sie eine POST-Anfrage mit einem gültigen JWT-Cookie senden, dass der Server mit dem Statuscode 200 und einer Meldung antwortet, die bestätigt, dass Sie sich erfolgreich abgemeldet haben. Zusätzlich überprüfen Sie, ob das JWT-Cookie gelöscht wurde, indem Sie prüfen, ob sein Wert leer ist und sein Ablaufdatum auf ein vergangenes Datum gesetzt ist.
Sie benötigen eine weitere Hilfsfunktion, um die Logout-Route zu implementieren.
// src/features/user-authentication/user-authentication-helpers.ts
/**
* Modifies the response to instruct the browser to delete the JWT cookie.
*
* @param response The response object to clear the cookie from.
*/
export function clearJwtCookie(response: Response) {
response.clearCookie(JWT_COOKIE_NAME, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
});
}Diese Funktion nimmt ein Antwortobjekt entgegen und löscht das JWT-Cookie, indem sie dessen Wert auf einen leeren String und dessen Ablaufdatum auf ein vergangenes Datum setzt.
Jetzt können Sie die Logout-Route implementieren.
// src/features/user-authentication/user-authentication-controller.ts
// ... other imports ...
import {
clearJwtCookie,
generateJwtToken,
getIsPasswordValid,
hashPassword,
setJwtCookie,
} from './user-authentication-helpers.js';
// ... other handlers ...
export async function logout(request: Request, response: Response) {
clearJwtCookie(response);
response.status(200).json({ message: 'Logged out successfully' });
}Diese Funktion löscht das JWT-Cookie und sendet einen 200er-Status mit einer Erfolgsmeldung.
Binden Sie diesen Handler ebenfalls ein.
// src/features/user-authentication/user-authentication-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import { login, logout, register } from './user-authentication-controller.js';
const router = Router();
router.post('/login', asyncHandler(login));
router.post('/register', asyncHandler(register));
router.post('/logout', asyncHandler(logout));
export { router as userAuthenticationRoutes };Jetzt sollte auch Ihr Logout-Test erfolgreich sein.
Eine weitere benötigte Funktion im Zusammenhang mit der Authentifizierung ist eine Middleware, die es Ihnen ermöglicht, Routen zu schützen, sodass diese nur von authentifizierten Benutzern verwendet werden können.
Um diese Middleware zu schreiben, benötigen Sie eine Hilfsfunktion zur Benutzerauthentifizierung, die prüft, ob ein Benutzer authentifiziert ist. Sie benötigen KEINE Tests für diese neue Hilfsfunktion, da diese Middleware
1.) nur mit Mocking testbar sein wird, und
2.) implizit durch Ihre Integrationstests gründlich getestet wird. (Wenn Sie möchten, könnten Sie testen isTokenValid, aber das überlasse ich Ihnen als Übung.)
// src/features/user-authentication/user-authentication-helpers.ts
// ... other imports ...
import type { Request, Response } from 'express';
// ... other imports ...
/**
* Check if a token is valid.
*
* @param token The token to check.
* @returns True if the token is valid, false otherwise.
*/
const isTokenValid = (
token: jwt.JwtPayload | string,
): token is TokenPayload => {
if (
typeof token === 'object' &&
token !== null &&
'id' in token &&
'email' in token
) {
return true;
}
return false;
};
/**
* Get the JWT token from the cookie.
*
* @param request The request object to get the cookie from.
* @returns The JWT token from the cookie.
*/
export function getJwtTokenFromCookie(request: Request) {
const token = request.cookies[JWT_COOKIE_NAME];
if (!token) {
throw new Error('No token found');
}
const decodedToken = jwt.verify(token, process.env.JWT_SECRET as string);
if (isTokenValid(decodedToken)) {
return decodedToken;
}
throw new Error('Invalid token payload');
}Sie erstellen eine Hilfsfunktion namens isTokenValid , die prüft, ob ein dekodiertes Token die korrekte Struktur hat (d.h. es ist ein Objekt, das eine id und eine E-Mail).
Dann schreiben Sie eine weitere Funktion, getJwtTokenFromCookie, die den JWT-Token aus den Cookies der Anfrage mithilfe eines vordefinierten Cookie-Namens extrahiert. Innerhalb dieser Funktion verifizieren Sie den Token mit Ihrem Geheimnis, überprüfen seine Gültigkeit mit isTokenValid, und wenn alles in Ordnung ist, geben Sie den dekodierten Token zurück. Wenn der Token fehlt oder ungültig ist, werfen Sie einen Fehler.
Jetzt können Sie die Middleware implementieren.
// src/middleware/require-authentication.ts
import type { Request, Response } from 'express';
import { getJwtTokenFromCookie } from '~/features/user-authentication/user-authentication-helpers.js';
/**
* Gets the user's token payload from the JWT token.
* Throws an error if no valid token exists.
*
* @param request The request object to get the token from.
* @returns The token payload containing the user's ID and email.
*/
export function requireAuthentication(request: Request, response: Response) {
try {
return getJwtTokenFromCookie(request);
} catch {
throw response.status(401).json({ message: 'Unauthorized' });
}
}Diese Middleware prüft einfach, ob ein Benutzer authentifiziert ist, indem sie versucht, dessen JWT-Token aus den Cookies der Anfrage abzurufen. Wenn dies gelingt, geben Sie die Token-Payload zurück (die die Benutzer-ID und E-Mail enthält). Wenn jedoch ein Problem auftritt (z. B. wenn der Token fehlt oder ungültig ist), fangen Sie den Fehler ab und antworten mit dem Status 401 Unauthorized zusammen mit einer Nachricht.
Genau wie validateBody, ist diese Middleware dazu gedacht, inline aufgerufen zu werden, damit sie mit TypeScript verwendet werden kann.
// temp-require-authentication-example.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import { requireAuthentication } from '../middleware/require-authentication.js';
const app = express();
app.use(cookieParser()); // Ensure cookie-parser is included
app.get('/protected/profile', async (request, response, next) => {
try {
// Inline usage of requireAuthentication to get the token payload
const { id, email } = requireAuthentication(request, response);
// Use the authenticated user's ID and email in the response
response.status(200).json({
message: `Hello, ${email}! Your user ID is ${id}`,
userId: id,
});
} catch (error) {
next(error); // Pass errors to Express error handling
}
});Nun, lassen Sie uns eine vollständige Funktion implementieren. Von diesem Zeitpunkt an würden Sie, wenn diese Codebasis die Grundlage einer Ihrer realen REST-APIs wäre, hauptsächlich weitere Routen hinzufügen, um verschiedene Funktionen in Ihrer Express-App zu unterstützen.
Und es gibt wirklich nur noch ein wichtiges Konzept zu lernen: wie man authentifizierte Benutzer in seinen Tests einrichtet. Sie werden sehen, wie dies mithilfe einer GET-Route geschieht, die eine Liste von Benutzerprofilen zurückgibt.
// src/features/user-profile/user-profile.test.ts
import type { UserProfile } from '@prisma/client';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';
import { buildApp } from '~/app.js';
import {
generateJwtToken,
JWT_COOKIE_NAME,
} from '../user-authentication/user-authentication-helpers.js';
import { createPopulatedUserProfile } from './user-profile-factories.js';
import {
deleteUserProfileFromDatabaseById,
saveUserProfileToDatabase,
} from './user-profile-model.js';
async function setup(numberOfProfiles = 1) {
const app = buildApp();
const profiles = await Promise.all(
Array.from({ length: numberOfProfiles }).map(() =>
saveUserProfileToDatabase(createPopulatedUserProfile()),
),
);
const authenticatedUser = createPopulatedUserProfile();
await saveUserProfileToDatabase(authenticatedUser);
const token = generateJwtToken(authenticatedUser);
onTestFinished(async () => {
try {
await Promise.all(
[...profiles, authenticatedUser].map(profile =>
deleteUserProfileFromDatabaseById(profile.id),
),
);
} catch {
// We need to catch here to handle tests that delete user profiles.
// If a test fails and the implementation code does NOT delete the user
// profiles, we need to delete them in the try block.
// If the test passes and the implementation code deletes the user
// profiles, this cleanup will not be needed and would throw, which is why
// we need to catch the error.
}
});
return {
app,
token,
profiles: profiles.sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
),
};
}
describe('/api/v1/user-profiles', () => {
describe('/', () => {
describe('GET', () => {
test('given: an unauthenticated request, should: return a 401', async () => {
const { app } = await setup();
const { status: actual } = await request(app).get(
'/api/v1/user-profiles',
);
const expected = 401;
expect(actual).toEqual(expected);
});
test('given: multiple profiles exist, should: return a 200 with paginated profiles', async () => {
const { app, profiles, token } = await setup(3);
const [first, second] = profiles as [UserProfile, UserProfile];
const actual = await request(app)
.get('/api/v1/user-profiles')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.query({ page: 1, pageSize: 2 })
.expect(200);
const expected = [
{
id: first.id,
email: first.email,
name: first.name,
createdAt: first.createdAt.toISOString(),
updatedAt: first.updatedAt.toISOString(),
},
{
id: second.id,
email: second.email,
name: second.name,
createdAt: second.createdAt.toISOString(),
updatedAt: second.updatedAt.toISOString(),
},
];
expect(actual.body).toEqual(expected);
expect(actual.body).toHaveLength(2);
});
test('given: query params exist, should: return a 200 with profiles for the requested page', async () => {
const { app, profiles, token } = await setup(5);
const [third, fourth] = profiles.slice(2, 4) as [
UserProfile,
UserProfile,
];
const actual = await request(app)
.get('/api/v1/user-profiles')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.query({ page: 2, pageSize: 2 })
.expect(200);
const expected = [
{
id: third.id,
email: third.email,
name: third.name,
createdAt: third.createdAt.toISOString(),
updatedAt: third.updatedAt.toISOString(),
hashedPassword: third.hashedPassword,
},
{
id: fourth.id,
email: fourth.email,
name: fourth.name,
createdAt: fourth.createdAt.toISOString(),
updatedAt: fourth.updatedAt.toISOString(),
hashedPassword: fourth.hashedPassword,
},
];
expect(actual.body).toEqual(expected);
expect(actual.body).toHaveLength(2);
});
test('given: no query params, should: return a 200 with default pagination values', async () => {
const { app, profiles, token } = await setup(15);
const firstTenProfiles = profiles.slice(0, 10);
const actual = await request(app)
.get('/api/v1/user-profiles')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(200);
const expected = firstTenProfiles.map(profile => ({
id: profile.id,
email: profile.email,
name: profile.name,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
hashedPassword: profile.hashedPassword,
}));
expect(actual.body).toEqual(expected);
expect(actual.body).toHaveLength(10);
});
});
});
});Erstellen Sie erneut eine Hilfsfunktion namens setup. In dieser Funktion erstellen Sie Ihre App, legen mehrere Benutzerprofile in Ihrer Datenbank an, richten eine Bereinigung ein, um diese nach Abschluss der Tests zu löschen, und generieren einen JWT-Token für einen authentifizierten Benutzer.
Als Nächstes definieren Sie eine Test-Suite für die /api/v1/user-profiles Endpoint:
page und pageSize) die korrekten paginierten Benutzerprofile zurückgibt.Während dieser Tests authentifizieren Sie die Anfrage, indem Sie das JWT-Token setzen, bevor Sie die Antwort erwarten.
// src/features/user-profile/user-profile-controller.ts
import type { Request, Response } from 'express';
import { z } from 'zod';
import { requireAuthentication } from '~/middleware/require-authentication.js';
import { validateQuery } from '~/middleware/validate.js';
import { retrieveManyUserProfilesFromDatabase } from './user-profile-model.js';
export async function getAllUserProfiles(request: Request, response: Response) {
requireAuthentication(request, response);
const query = await validateQuery(
z.object({
page: z.coerce.number().positive().default(1),
pageSize: z.coerce.number().positive().default(10),
}),
request,
response,
);
const profiles = await retrieveManyUserProfilesFromDatabase({
page: query.page,
pageSize: query.pageSize,
});
response.status(200).json(profiles);
}Mit Ihrer Middleware und Ihren Fassaden ist die Implementierung der Route trivial. Zuerst validieren Sie, dass der Benutzer authentifiziert ist, dann validieren Sie die Abfrageparameter und schließlich rufen Sie die Profile aus der Datenbank ab.
Schließen Sie nun den Handler an.
// src/features/user-profile/user-profile-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import { getAllUserProfiles } from './user-profile-controller.js';
const router = Router();
router.get('/', asyncHandler(getAllUserProfiles));
export { router as userProfileRoutes };Und binden Sie diesen Router in Ihrem apiV1Router.
// src/routes.ts
import { Router } from 'express';
import { healthCheckRoutes } from '~/features/health-check/health-check-routes.js';
import { userAuthenticationRoutes } from '~/features/user-authentication/user-authentication-routes.js';
import { userProfileRoutes } from '~/features/user-profile/user-profile-routes.js';
export const apiV1Router = Router();
apiV1Router.use('/health-check', healthCheckRoutes);
apiV1Router.use(userAuthenticationRoutes);
apiV1Router.use('/user-profiles', userProfileRoutes);Jetzt bestehen die Tests für die Listen-GET-Route.
Wenn du das Gelernte üben möchtest, implementiere die Routen TDD-basiert, um ein einzelnes Benutzerprofil per ID abzurufen, ein Benutzerprofil per ID zu aktualisieren und ein Benutzerprofil per ID zu löschen.
// src/features/user-profile/user-profile.test.ts
import { createId } from '@paralleldrive/cuid2';
import type { UserProfile } from '@prisma/client';
import request from 'supertest';
import { describe, expect, onTestFinished, test } from 'vitest';
import { buildApp } from '~/app.js';
import {
generateJwtToken,
JWT_COOKIE_NAME,
} from '../user-authentication/user-authentication-helpers.js';
import { createPopulatedUserProfile } from './user-profile-factories.js';
import {
deleteUserProfileFromDatabaseById,
saveUserProfileToDatabase,
} from './user-profile-model.js';
// ... setup function ...
describe('/api/v1/user-profiles', () => {
describe('/', () => {
// ... GET list route tests ...
});
describe('/:id', () => {
describe('GET', () => {
test('given: an unauthenticated request, should: return a 401', async () => {
const { app, profiles } = await setup();
const [profile] = profiles as [UserProfile];
const { status: actual } = await request(app).get(
`/api/v1/user-profiles/${profile.id}`,
);
const expected = 401;
expect(actual).toEqual(expected);
});
test('given: profile exists, should: return a 200 with the profile', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const actual = await request(app)
.get(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(200);
const expected = {
id: profile.id,
email: profile.email,
name: profile.name,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
hashedPassword: profile.hashedPassword,
};
expect(actual.body).toEqual(expected);
});
test('given: profile does not exist, should: return a 404 with error message', async () => {
const { app, token } = await setup(0);
const nonExistentId = createId();
const actual = await request(app)
.get(`/api/v1/user-profiles/${nonExistentId}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(404);
const expected = { message: 'Not Found' };
expect(actual.body).toEqual(expected);
});
});
describe('PATCH', () => {
test('given: an unauthenticated request, should: return a 401', async () => {
const { app, profiles } = await setup();
const [profile] = profiles as [UserProfile];
const updates = { name: 'Updated Name' };
const { status: actual } = await request(app)
.patch(`/api/v1/user-profiles/${profile.id}`)
.send(updates);
const expected = 401;
expect(actual).toEqual(expected);
});
test('given: profile exists and valid update data, should: return a 200 with the updated profile', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const updates = { name: 'Updated Name', ignoredField: 'ignoreMe' };
const actual = await request(app)
.patch(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send(updates)
.expect(200);
const expected = {
id: profile.id,
email: profile.email,
name: updates.name,
createdAt: profile.createdAt.toISOString(),
updatedAt: actual.body.updatedAt,
hashedPassword: profile.hashedPassword,
};
expect(actual.body).toEqual(expected);
});
test('given: invalid id, should: return a 404 with an error message', async () => {
const { app, token } = await setup(0);
const updates = { name: 'Updated Name' };
const nonExistentId = createId();
const actual = await request(app)
.patch(`/api/v1/user-profiles/${nonExistentId}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send(updates)
.expect(404);
const expected = { message: 'Not Found' };
expect(actual.body).toEqual(expected);
});
test('given: empty update object, should: return a 400 with an error message', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const actual = await request(app)
.patch(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send({})
.expect(400);
const expected = { message: 'No valid fields to update' };
expect(actual.body).toEqual(expected);
});
test('given: attempt to update id, should: return a 400 with an error message', async () => {
const { app, profiles, token } = await setup();
const [profile] = profiles as [UserProfile];
const updates = { id: 'new-id' };
const actual = await request(app)
.patch(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send(updates)
.expect(400);
const expected = {
message: 'Bad Request',
errors: [
{
code: 'invalid_type',
expected: 'never',
message: 'Expected never, received string',
path: ['id'],
received: 'string',
},
],
};
expect(actual.body).toEqual(expected);
});
test('given: missing id in URL, should: return a 404', async () => {
const { app, token } = await setup();
const updates = { name: 'Updated Name' };
const actual = await request(app)
.patch('/api/v1/user-profiles/')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.send(updates);
const expected = 404;
expect(actual.status).toEqual(expected);
});
});
describe('DELETE', () => {
test('given: an unauthenticated request, should: return a 401', async () => {
const { app, profiles } = await setup();
const [profile] = profiles as [UserProfile];
const { status: actual } = await request(app).delete(
`/api/v1/user-profiles/${profile.id}`,
);
const expected = 401;
expect(actual).toEqual(expected);ƒ
});
test('given: existing profile, should: return a 200 with the deleted profile', async () => {
const { app, profiles, token } = await setup(1);
const [profile] = profiles as [UserProfile];
const actual = await request(app)
.delete(`/api/v1/user-profiles/${profile.id}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(200);
const expected = {
id: profile.id,
email: profile.email,
name: profile.name,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
hashedPassword: profile.hashedPassword,
};
expect(actual.body).toEqual(expected);
});
test('given: profile does not exist, should: return a 404 with an error message', async () => {
const { app, token } = await setup(0);
const nonExistentId = createId();
const actual = await request(app)
.delete(`/api/v1/user-profiles/${nonExistentId}`)
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`])
.expect(404);
const expected = { message: 'Not Found' };
expect(actual.body).toEqual(expected);
});
test('given: missing id in URL, should: return a 404', async () => {
const { app, token } = await setup();
const actual = await request(app)
.delete('/api/v1/user-profiles/')
.set('Cookie', [`${JWT_COOKIE_NAME}=${token}`]);
const expected = 404;
expect(actual.status).toEqual(expected);
});
});
});
});Für GET-Anfragen testest du das Abrufen eines einzelnen Profils anhand seiner ID. Wenn das Profil existiert, erwartest du, dass die API eine 200-Antwort mit den korrekten Profildaten zurückgibt; wenn es nicht existiert, erwartest du eine 404-Antwort mit einer entsprechenden Fehlermeldung.
Für PATCH-Anfragen prüfst du verschiedene Aktualisierungsszenarien. Du überprüfst, dass nicht authentifizierte Aktualisierungsversuche eine 401 zurückgeben und dass gültige Aktualisierungsdaten für ein bestehendes Profil eine 200 mit dem aktualisierten Profil zurückgeben. Zusätzlich testest du Grenzfälle wie das Aktualisieren eines nicht existierenden Profils, das Senden eines leeren Update-Objekts oder den Versuch, unveränderliche Felder wie die Profil-ID zu modifizieren, die alle entsprechende Fehlermeldungen auslösen sollten.
Für DELETE-Anfragen bestätigst du, dass nicht authentifizierte Löschversuche mit einer 401 abgewiesen werden. Wenn du ein bestehendes Profil mit gültiger Authentifizierung löschst, erwartest du eine 200-Antwort mit den Details des gelöschten Profils. Du testest auch, dass der Versuch, ein nicht existierendes Profil zu löschen oder die Profil-ID in der URL wegzulassen, zu einer 404 mit einer Fehlermeldung führt.
Es gibt viele verschiedene Möglichkeiten, diese Routen zu implementieren, aber ich lasse Prisma gerne nach Duplikaten suchen, was einen Fehler auslösen wird. Um den korrekten Fehler zu identifizieren, kann eine einfache Hilfsfunktion namens get-error-message verwendet werden. Erstelle Tests dafür.
// src/utils/get-error-message.test.ts
import { faker } from '@faker-js/faker';
import { describe, expect, test } from 'vitest';
import { getErrorMessage } from './get-error-message.js';
describe('getErrorMessage()', () => {
test("given: an error, should: return the error's message", () => {
const message = faker.word.words();
expect(getErrorMessage(new Error(message))).toEqual(message);
});
test('given: a string is thrown, should: return the string', () => {
expect.assertions(1);
const someString = faker.lorem.words();
try {
throw someString;
} catch (error) {
expect(getErrorMessage(error)).toEqual(someString);
}
});
test('given: a number is thrown, should: return the number as a string', () => {
expect.assertions(1);
const someNumber = 1;
try {
throw someNumber;
} catch (error) {
expect(getErrorMessage(error)).toEqual(JSON.stringify(someNumber));
}
});
test("given: an error that extends a custom error class, should: return the error's message", () => {
class CustomError extends Error {
public constructor(message: string) {
super(message);
}
}
const message = faker.word.words();
expect(getErrorMessage(new CustomError(message))).toEqual(message);
});
test("given: a custom error object with a message property, should: return the object's message property", () => {
const message = faker.word.words();
expect(getErrorMessage({ message })).toEqual(message);
});
test('given: circular references, should: handle them gracefully', () => {
expect.assertions(1);
const object = { circular: this };
try {
throw object;
} catch (error) {
expect(getErrorMessage(error)).toEqual('[object Object]');
}
});
});Diese Tests prüfen verschiedene Fehlertypen, die an getErrorMessage()übergeben werden. Sie überprüfen, dass getErrorMessage() die Nachricht korrekt aus einem Standard- Error, einen String zurückgibt, wenn einer geworfen wird, und eine geworfene Zahl als JSON-String darstellt. Es überprüft auch, dass die Funktion benutzerdefinierte Fehler und Objekte mit einer message -Eigenschaft korrekt behandelt und zirkuläre Referenzen elegant verwaltet, indem sie eine Standard-String-Darstellung zurückgibt.
Implementiere nun die getErrorMessage() -Funktion.
// src/utils/get-error-message.ts
type ErrorWithMessage = {
message: string;
};
// This validates an existing message property in standard errors, custom errors
// and objects with a message property.
function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
);
}
function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
if (isErrorWithMessage(maybeError)) return maybeError;
try {
if (typeof maybeError === 'string') return new Error(maybeError);
return new Error(JSON.stringify(maybeError));
} catch {
// JSON.stringify() would throw in the case of a circular reference. We then
// catch it here and coerce it into the [object Object] string.
return new Error(String(maybeError));
}
}
/**
* Get the error message from an error or any other thing that has been thrown.
*
* @param error - Something that has been thrown and might be an error.
* @returns A string containing the error message.
*
* @example
*
* Used on an Error instance:
*
* ```ts
* getErrorMessage(new Error('Something went wrong'))
* // ↵ 'Something went wrong'
* ```
*
* Used on a non-error object:
*
* ```ts
* getErrorMessage({ message: 'Something went wrong' })
* // ↵ 'Something went wrong'
* ```
*
* Used on a non-error object with no message property (e.g. a primitive):
*
* ```ts
* getErrorMessage('Something went wrong')
* // ↵ '"some-string"'
* ```
*/
export function getErrorMessage(error: unknown) {
return toErrorWithMessage(error).message;
}Zunächst definieren Sie einen ErrorWithMessage -Typ, um sicherzustellen, dass ein Objekt eine Zeichenkette message -Eigenschaft besitzt, und implementieren dann einen Type Guard isErrorWithMessage , um dies zu überprüfen.
Als Nächstes definieren Sie eine toErrorWithMessage -Funktion, die jeden geworfenen Wert in ein ErrorWithMessage -Objekt umwandelt, indem sie es entweder zurückgibt, falls gültig, eine Zeichenkette in ein neues Error-Objekt verpackt oder versucht, den Wert in JSON zu serialisieren – mit einem Fallback auf String() im Fehlerfall.
Schließlich extrahiert getErrorMessage die message Eigenschaft aus dem konvertierten Objekt, um eine konsistente Fehlermeldung zu gewährleisten.
Jetzt können Sie die Routen implementieren.
// src/features/user-profile/user-profile-controller.ts
// ... other imports ...
import {
validateBody,
validateParams,
validateQuery,
} from '~/middleware/validate.js';
import { getErrorMessage } from '~/utils/get-error-message.js';
import {
deleteUserProfileFromDatabaseById,
retrieveManyUserProfilesFromDatabase,
retrieveUserProfileFromDatabaseById,
updateUserProfileInDatabaseById,
} from './user-profile-model.js';
// ... get list of users handler ...
export async function getUserProfileById(request: Request, response: Response) {
requireAuthentication(request, response);
const { id } = await validateParams(
z.object({ id: z.string().cuid2() }),
request,
response,
);
const profile = await retrieveUserProfileFromDatabaseById(id);
if (profile) {
response.status(200).json(profile);
} else {
response.status(404).json({ message: 'Not Found' });
}
}
export async function updateUserProfile(request: Request, response: Response) {
requireAuthentication(request, response);
const { id } = await validateParams(
z.object({ id: z.string().cuid2() }),
request,
response,
);
const body = await validateBody(
z.object({
email: z.string().email().optional(),
name: z.string().optional(),
id: z.never().optional(),
}),
request,
response,
);
// Check if there are any fields to update.
if (Object.keys(body).length === 0) {
response.status(400).json({ message: 'No valid fields to update' });
return;
}
// Check if trying to update id.
if ('id' in body) {
response.status(400).json({ message: 'ID cannot be updated' });
return;
}
try {
const updatedProfile = await updateUserProfileInDatabaseById({
id,
data: body,
});
response.status(200).json(updatedProfile);
} catch (error) {
const message = getErrorMessage(error);
if (message.includes('Record to update not found')) {
response.status(404).json({ message: 'Not Found' });
} else if (message.includes('Unique constraint failed')) {
response.status(409).json({ message: 'Profile already exists' });
} else {
throw error;
}
}
}
export async function deleteUserProfile(request: Request, response: Response) {
requireAuthentication(request, response);
const { id } = await validateParams(
z.object({ id: z.string().cuid2() }),
request,
response,
);
try {
const deletedProfile = await deleteUserProfileFromDatabaseById(id);
response.status(200).json(deletedProfile);
} catch (error) {
const message = getErrorMessage(error);
if (message.includes('Record to delete does not exist')) {
response.status(404).json({ message: 'Not Found' });
} else {
throw error;
}
}
}Binden Sie die Routen in den Benutzerprofil-Router ein.
// src/features/user-profile/user-profile-routes.ts
import { Router } from 'express';
import { asyncHandler } from '~/utils/async-handler.js';
import {
deleteUserProfile,
getAllUserProfiles,
getUserProfileById,
updateUserProfile,
} from './user-profile-controller.js';
const router = Router();
router.get('/', asyncHandler(getAllUserProfiles));
router.get('/:id', asyncHandler(getUserProfileById));
router.patch('/:id', asyncHandler(updateUserProfile));
router.delete('/:id', asyncHandler(deleteUserProfile));
export { router as userProfileRoutes };Jetzt sollten alle Ihre Tests erfolgreich sein.
Sie müssen keine Erstellungsroute für Benutzerprofile implementieren, da das Erstellen eines Benutzerprofils dasselbe ist wie die Registrierung eines Benutzers. Natürlich müssen Sie in einigen Anwendungen möglicherweise zulassen, dass Benutzer Konten für andere erstellen, in welchem Fall Sie diese Route ebenfalls benötigen würden.
Doch an diesem Punkt haben Sie bereits 20 % der Express mit TypeScript-Kenntnisse erworben, die 80 % der realen Anwendungen abdecken werden. Gehen Sie jetzt los und entwickeln Sie etwas!