Эта статья поможет вам создать приложение Express 5 с поддержкой TypeScript.
Вы настроите готовый к продакшну проект с помощью различных инструментов для линтинга, тестирования и проверки типов. В случае, если вы новичок в REST API, не волнуйтесь, эта статья также включает объяснения основных концепций, которые следует знать, таких как маршрутизация (роутинг) и аутентификация.
Настоятельно рекомендую писать код вместе со мной. Мы будем использовать подход «Разработка через тестирование» (test-driven development, TDD) для создания REST API, который может стать основой вашего следующего приложения Express.
Прим. пер.: в коде оригинальной статьи встречаются устаревшие (deprecated) свойства и методы. Также некоторые оригинальные тесты работают нестабильно. Я позволил себе внести необходимые коррективы. Однако, учитывая объем материала, я вполне мог что-то упустить, поэтому вот ссылка на мой вариант полностью работоспособного кода приложения.
❯ Инициализация проекта
Создаем новую директорию для проекта:
mkdir express-ts-app
cd express-ts-app
Инициализируем проект с помощью npm
:
npm init -y
Создаем файл package.json
в директории проекта.
Модифицируем поле "main"
и добавляем "type": "module"
в этот файл:
{
"main": "dist/index.js",
"type": "module"
// Другие поля...
}
Устанавливаем Express:
npm i express
Устанавливаем TypeScript и необходимые определения типов как зависимости для разработки:
npm i -D typescript tsx @types/node @types/express
Инициализируем TypeScript:
npx tsc --init
Эта команда создает файл 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/**/*"
]
}
Создаем директорию src
для исходных файлов приложения:
mkdir src
Внутри 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}`);
});
Добавляем в package.json
скрипты для запуска и сборки приложения:
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
}
build
компилирует TS в JSstart
запускает скомпилированный код JSdev
запускает код TS напрямую с перезагрузкой в реальном времени (hot reload)
Запускаем сервер для разработки:
npm run dev
Переходим по адресу http://localhost:3000
и убеждаемся в корректной работе сервера.
Для быстрой проверки работоспособности сервера можно использовать curl
:
curl http://localhost:3000
Express + TypeScript Server
❯ ESLint и Prettier
Устанавливаем ESLint и Prettier для обеспечения согласованного стиля кода и раннего перехвата потенциальных ошибок:
npm i -D eslint typescript-eslint eslint-config-prettier eslint-plugin-prettier eslint-plugin-simple-import-sort eslint-plugin-unicorn prettier @vitest/eslint-plugin
Создаем файл 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,
};
Прим. пер.: я добавил в этот файл endOfLine: 'auto'
для правильной обработки символов переноса строки в Windows.
Создаем файл eslint.config.js
:
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
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 defineConfig([
eslint.configs.recommended,
...tseslint.configs.recommended,
eslintPluginUnicorn.configs['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': 'off',
},
},
{
files: ['src/**/*.test.{js,ts}'],
...vitest.configs.recommended,
},
eslintPluginPrettierRecommended,
]);
Эта настройка комбинирует несколько наборов правил ESLint.
Сначала расширяются рекомендуемые правила JS и TS, затем добавляются предложения Unicorn по улучшению кода (некоторые из них кастомизируются).
Она также включает плагин simple-import-sort
для автоматической сортировки инструкций импорта и экспорта.
Для тестов применяются рекомендуемые правила Vitest для обеспечения их следования лучшим практикам.
Наконец, добавляется плагин Prettier для интеграции форматирования кода в процесс линтинга, поэтому код остается одновременно синтаксически верным и стилистически согласованным.
Добавляем в package.json
скрипты для линтинга и форматирования:
"scripts": {
"format": "prettier --write .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
}
❯ Vitest и Supertest
Устанавливаем зависимости для тестирования:
npm i -D vitest vite-tsconfig-paths supertest @types/supertest @faker-js/faker
Создаем файл vitest.config.ts
:
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [tsconfigPaths()],
test: { environment: 'node' },
});
Добавляем скрипт для запуска тестов в package.json
:
"scripts": {
"test": "vitest"
}
❯ Разделение на сервер и приложение
Файл src/index.ts
сейчас выполняет 2 функции. Он является и приложением, и сервером.
В контексте создания REST API с помощью Express «app» указывает на приложение Express. Приложение содержит посредников (промежуточное ПО, middleware) и роуты, а также обрабатывает запросы HTTP. Другими словами, приложение - это логика, выполняемая на сервере.
«server» - это сервер HTTP. Он регистрирует сетевые соединения и создается при вызове app.listen()
.
Прим. пер.: автор предлагает удалить index.ts
и создать 2 новых файла. Я предлагаю сделать немного проще.
Создаем файл src/app.ts
:
import express from 'express';
export function buildApp() {
const app = express();
// Посредник для разбора (парсинга) JSON
app.use(express.json());
return app;
}
Посредник express.json()
нужен для обработки данных в формате JSON из входящих запросов.
Модифицируем файл src/index.ts
:
import { buildApp } from './app.js';
const port = Number(process.env.PORT) || 3000;
const app = buildApp();
// Запускаем сервер и перехватываем возвращаемый экземпляр сервера
const server = app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
// Обрабатываем сигнал SIGTERM для мягкой (gracefully) остановки сервера
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
});
});
Обратите внимание на расширение .js
при импорте файла app.ts
. При указании "module": "NodeNext"
в tsconfig.json
TS следует правилу разрешения модулей Node.js, которое требует явного указания расширений в импортах. Несмотря на то, что мы пишем код на TS, он компилируется в JS, поэтому нужно импортировать файлы .js
(например, import { buildApp } from './app.js'
). Это гарантирует, что Node.js обнаружит правильные файлы во время выполнения, и предотвращает ошибки.
❯ Логгирование
При создании серверов необходимо мониторить поведение системы путем отслеживания запросов, что помогает в поиске и устранении проблем. Популярным решением является использование такого посредника, как morgan
.
Устанавливаем зависимости:
npm i morgan && npm i -D @types/morgan
Добавляем morgan
в приложение:
// src/index.ts
import morgan from 'morgan';
import { buildApp } from './app.js';
const port = Number(process.env.PORT) || 3000;
const app = buildApp();
// Настраиваем логгирование с помощью `morgan` на основе среды выполнения кода
const environment = process.env.NODE_ENV || 'development';
app.use(environment === 'development' ? morgan('dev') : morgan('tiny'));
const server = app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
});
});
Формат логов morgan
настраивается в зависимости от среды выполнения кода. Формат dev
предоставляет цветные логи для локальной разработки, а tiny
- минимальные логи для продакшна.
morgan
лучше настраивать в коде сервера, поскольку в тестах будет использоваться buildApp()
. Настройка morgan
в коде приложения будет загромождать вывод тестов лишними логами.
❯ Группировка по функционалу
Перед реализацией первой фичи (feature), обсудим общую структуру приложения Express.
В этом туториале файлы будут группироваться по функционалу (фичам). Вот типичная структура такого приложения Express:
.
├── eslint.config.js
├── package-lock.json
├── package.json
├── prettier.config.js
├── src
│ ├── app.ts
│ ├── features
│ │ ├── другие фичи...
│ │ └── feature
│ │ ├── ...
│ │ ├── feature-model.ts
│ │ ├── feature-controller.ts
│ │ ├── feature-routes.ts
│ │ └── feature.test.ts
│ ├── другие директории...
│ ├── routes.ts
│ └── server.ts
├── tsconfig.json
└── vitest.config.ts
При разработке приложения Express, как правило, следуют шаблону MVC:
model (модель) - это код, взаимодействующий с базой данных или внешними API
view (представление) - код, отвечающий за отображение данных и пользовательский интерфейс
controller (контроллер) - логика, выполняемая при доступе к роуту. Он соединяет модель и представление, обновляет модель и определяет представление для отображения
Если ваше приложение - это чистый бэкенд REST API, как в этом туториале, вам не нужен слой представления.
❯ Роуты, конечные точки и контроллеры
В дизайне API роут (route) определяет путь (path) и метод HTTP (например, GET, POST), которые используются клиентом для доступа к определенному ресурсу или функционалу. Конечная точка (endpoint) - это определенный URL, по которому доступен этот ресурс или функционал. Контроллер содержит логику, выполняемую при доступе к роуту. Таким образом, роуты и конечные точки определяют, как и где клиенты могут получить ресурсы, а контроллеры - что происходит при доступе к этим роутам.
Роуты и конечные точки часто используются как синонимы, но технически:
роут представляет собой комбинацию метода HTTP и пути URL
конечная точка представляет собой конкретный URL (который может содержать метод при выполнении полной операции API)
контроллер - это контейнер для связанных методов/операций/обработчиков. Обычно, в нем определяется логика обработки запросов к роутам/конечным точкам
метод/операция (action) - функция контроллера для обработки определенных запросов
Рассмотрим такой запрос HTTP:
GET https://api.example.com/users/123
конечная точка - https://api.example.com/users/123
роут - GET /users/:id
операция контроллера - функция
getUserById
(операция/метод/обработчик) в объектеuserController
и/или в файлеuser-controller.ts
В случае длинных роутов, таких как /api/v1/organizations/:slug/members/:id
, конечная точки может выглядеть так:
GET https://api.example.com/api/v1/organizations/acme/members/123
Каждая часть роута имеет свое название:
/api - основной путь (base path) или пространство имен (namespace) API
/v1 - сегмент версии API
/organizations - путь первичного (primary) ресурса
/:slug - параметр роута для идентификатора организации
/members - путь вложенного (nested) ресурса - /:id - параметр роута для идентификатора участника
❯ Конечная точка проверки здоровья
Приложение настроено, и мы готовы писать первый тест для первой фичи.
Начнем с создания простой конечной точки проверки здоровья (health check). Такие конечные точки позволяют системам мониторинга, таким как балансировщики нагрузки или оркестраторы (например, Kubernetes), определять, что приложение работает правильно и готово к обработке траффика. Эти системы помогают обнаружить проблемы, такие как упавшие процессы, сервисы или зависимости. Оркестраторы позволяют восстанавливать состояние приложение и мягко откатывать новые версии.
Создаем тест для конечной точки проверки здоровья:
// 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('дано: запрос GET, ожидается: возврат статуса 200 с сообщением, отметкой времени и временем работы', 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);
});
});
Добавляем контроллер с одним обработчиком для конечной точки проверки здоровья:
// 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);
}
}
Здесь мы просто создаем объект, содержащий сообщение, отметку времени и время работы, и отправляем его в формате JSON с дефолтным статусом 200.
Мы используем блок try-catch
для обработки ошибок и вызываем функцию next
для передачи ошибки соответствующему посреднику. Мы не создавали таких посредников, поэтому Express будет использовать своего встроенного посредника для обработки ошибок. Этот обработчик выводит ошибку в консоль и отправляет простой ответ с ошибкой клиенту, например, статус-код HTTP 500
и сообщение Internal Server Error
.
Каждая фича должна иметь хотя бы одного контроллера и один роутер. Создаем роутер:
// 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 };
Создаем основной файл для всех роутов:
// 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);
Мы определяем основной путь /health-check
для роутов проверки здоровья, где /health-check
- это путь основного ресурса.
В дальнейшем при миграции API в файле routes.ts
можно определить другую версию API (например, apiV2Router
).
Добавляем роуты в приложение:
// src/app.ts
import type { Express } from 'express';
import express from 'express';
import { apiV1Router } from './routes.js';
export function buildApp(): Express {
const app = express();
app.use(express.json());
// Группируем роуты под /api/v1
app.use('/api/v1', apiV1Router);
return app;
}
Запускаем тест:
npm run test
✓ 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
❯ asyncHandler
Шаблон использования next()
в обработчиках является довольно утомительным. Он заставляет использовать 3 аргумента, добавляет дополнительный слой и делает код менее читаемым и более объемным.
Создадим вспомогательную функцию, оборачивающую обработчик в блок try-catch
и вызывающую next()
с ошибкой:
// 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';
/**
* Утилита, оборачивающая асинхронный обработчик роута (без `next()`), чтобы любая ошибка автоматически
* передавалась в `next()`. Это позволяет избежать включения блоков `try/catch` в каждый асинхронный обработчик.
*
* @param fn Асинхронный обработчик запросов Express, возвращающий промис.
* @returns Стандартный обработчик запросов Express.
*/
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);
}
};
}
Здесь много строчек кода - все ради того, чтобы TS был счастлив. На самом деле, все сводится к этому:
// temp-async-handler.js
function asyncHandler(fn) {
return async function (request, response, next) {
try {
await fn(request, response);
} catch (error) {
next(error);
}
};
}
Мы вызываем asyncHandler()
с обработчиком, и она возвращает новый обработчик, который можно использовать в роутере.
Это позволяет упростить код обработчика:
// 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);
}
Добавляем asyncHandler()
в файл 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 };
❯ База данных
В этом туториале мы будем использовать Prisma с PostgreSQL. Установите сервер Postgres для создания локальной БД.
Прим. пер.: команда для создания Postgres в Docker:
docker run --name db -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=mydb -v postgres_data:/var/lib/postgresql/data -d postgres
Устанавливаем зависимости:
npm i -D prisma && npm i @prisma/client @paralleldrive/cuid2
Инициализируем Prisma:
npx prisma init
Эта команда генерирует файлы .env
и prisma/prisma.schema
. Убедитесь, что переменная DATABASE_URL
в .env
содержит правильные данные для подключения к БД.
Прим. пер.: DATABASE_URL
для Postgres в Docker:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mydb?schema=public"
Добавляем следующие скрипты в 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",
Для этого туториала важен только скрипт prisma:setup
. Он создает БД и генерирует клиента Prisma.
Полное объяснение всех скриптов можно найти в моей статье "How To Set Up Next.js 15 For Production In 2025".
Добавляем модель UserProfile
в файл prisma/schema.prisma
:
model UserProfile {
id String @id @default(cuid(2))
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @default("")
hashedPassword String
}
Прим. пер.: рекомендую установить расширение Prisma для VSCode.
Выполняем команду npm run prisma:setup
.
Создаем файл database.ts
для подключения к БД:
// 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 при каждой hot reload в режиме для разработки.
❯ Фасады
При работе с любым внешним API, БД или другим сервисом хорошей идеей является создание фасада (facade). Фасад - это обертка сервиса, предоставляющая упрощенный интерфейс для сложной подсистемы.
Фасады полезны по двум причинам:
Рост сопротивления поставщиков - фасады позволяют быстро оборачивать поставщиков (провайдеров). Например, переключение с Postgres на MongoDB путем одного изменения. Мы обновляем реализацию (структуру) фасада, а код, использующий фасад, остается прежним.
Упрощение кода - фасад ограничивает API тем, что нам требуется. Он уменьшает количество кода, который надо писать, поскольку мы передаем лишь нужные аргументы и получаем только нужные нам значения. Код также становится чище за счет описательных названий функций.
Создаем файл для фасада:
// src/features/user-profile/user-profile-model.ts
import { prisma } from '~/database.js';
import type { Prisma, UserProfile } from '~/generated/prisma/index.js';
/* CREATE */
/**
* Сохраняет профиль пользователя в БД.
*
* @param userProfile Профиль пользователя для сохранения.
* @returns Сохраненный профиль пользователя.
*/
export async function saveUserProfileToDatabase(
userProfile: Prisma.UserProfileCreateInput,
) {
return prisma.userProfile.create({ data: userProfile });
}
/* READ */
/**
* Извлекает профиль пользователя по его id.
*
* @param id Идентификатор профиля пользователя.
* @returns Профиль пользователя или `null`.
*/
export async function retrieveUserProfileFromDatabaseById(
id: UserProfile['id'],
) {
return prisma.userProfile.findUnique({ where: { id } });
}
/**
* Извлекает профиль пользователя по его email.
*
* @param email email профиля пользователя.
* @returns Профиль пользователя или `null`.
*/
export async function retrieveUserProfileFromDatabaseByEmail(
email: UserProfile['email'],
) {
return prisma.userProfile.findUnique({ where: { email } });
}
/**
* Извлекает несколько профилей пользователей.
*
* @param page Номер страницы (начиная с 1).
* @param pageSize Количество профилей на страницу.
* @returns Список профилей пользователей.
*/
export async function retrieveManyUserProfilesFromDatabase({
page = 1,
pageSize = 10,
}: {
page?: number;
pageSize?: number;
}) {
const skip = (page - 1) * pageSize;
return prisma.userProfile.findMany({
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
});
}
/* UPDATE */
/**
* Обновляет профиль пользователя по его id.
*
* @param id Идентификатор профиля пользователя.
* @param data Новые данные профиля.
* @returns Обновленный профиль пользователя.
*/
export async function updateUserProfileInDatabaseById({
id,
data,
}: {
id: UserProfile['id'];
data: Prisma.UserProfileUpdateInput;
}) {
return prisma.userProfile.update({ where: { id }, data });
}
/* DELETE */
/**
* Удаляет профиль пользователя по его id.
*
* @param id Идентификатор профиля пользователя.
* @returns Удаленный профиль пользователя.
*/
export async function deleteUserProfileFromDatabaseById(id: UserProfile['id']) {
return prisma.userProfile.delete({ where: { id } });
}
Прим. пер.: обратите внимание, что в оригинале типы Prisma
и UserProfile
импортируются из @prisma/client
. В новых версиях Prisma они должны импортироваться из ~/generated/prisma/index.js
.
Как правило, мы создаем полный набор операций CRUD (Create, Read, Update, Delete) для любой модели в соответствующем файле.
Для создания профиля пользователя экспортируется функция, принимающая профиль и записывающая его в БД с помощью метода Prisma create
. Это демонстрирует шаблон фасада в действии: Prisma, сложная подсистема, предоставляет большой API со множеством возможностей, но фасад упрощает его до простого сохранения одного профиля пользователя.
В разделе чтения имеются функции для извлечения профиля пользователя по уникальному id или email, а также функция для получения нескольких профилей с пагинацией и упорядочением по убыванию (самые последние (по времени создания) профили находятся в начале списка).
Операция обновления обрабатывается функцией, принимающей id и набор новых данных и обновляющей соответствующий профиль пользователя в БД.
Наконец, функция deleteUserProfileFromDatabaseById
удаляет профиль с указанным id.
❯ Фабричные функции
Фабричная функция (factory function) - это просто функция, которая возвращает объект. Этот объект обычно представляет осмысленную единицу приложения, такую как запись в БД, кастомная структура данных или объект в ООП. Позже мы будем использовать фабричные функции для создания фиктивных данных для тестов.
Создаем общий тип Factory
, который будет использоваться любой фабрикой:
// src/utils/types.ts
/**
* Произвольная фабричная функция с сигнатурой `Shape`.
*/
export type Factory<Shape> = (object?: Partial<Shape>) => Shape;
Этот тип позволяет перезаписывать дефолтные значения объекта, обеспечивая наличия всех необходимых свойств.
Создаем фабричную функцию для профиля пользователя:
// src/features/user-profile/user-profile-factories.ts
import { faker } from '@faker-js/faker';
import { createId } from '@paralleldrive/cuid2';
import type { UserProfile } from '~/generated/prisma/index.js';
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 });
Эта функция позволяет легко создавать профили пользователей с фиктивными данными.
❯ Валидация
Для валидации поисковых строк (queries) и тел запросов (request bodies) мы будем использовать Zod. Обычно, для этого используется express-validator
, но он плохо работает с TS, поскольку Express не умеет выводить типы структур данных.
Устанавливаем zod
:
npm i zod
Создаем посредника для валидации:
import type { Request, Response } from 'express';
import type { ZodType } from 'zod';
import { ZodError } from 'zod';
export function createValidate(key: 'body' | 'query' | 'params') {
return async function validate<T>(
schema: ZodType<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.issues });
throw new Error('Validation failed');
}
throw error;
}
};
}
export const validateBody = createValidate('body');
export const validateQuery = createValidate('query');
export const validateParams = createValidate('params');
Функция createValidate
принимает ключ и возвращает функцию для валидации тела запроса, поисковой строки или параметров запроса с помощью метода parseAsync
схемы Zod.
Если вам интересно, в чем разница между body
, query
и params
, вот краткое объяснение:
body
содержит данные, отправляемые в качестве полезной нагрузки (payload) запроса (часто используется с методами POST, PUT и др.) и обычно разбираемые с помощью посредника, такого какbody-parser
query
содержит пары «ключ-значение» из поисковой строки URL (часть после?
), часто используется для фильтрации или пагинацииparams
содержит параметры роута, определенные в пути URL (например,id
в/users/:id
), используется для захвата определенных сегментов URL
Помните, я сказал, что express-validator
плохо работает с TS? express-validator
обычно используется так:
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);
В этом сниппете TS не знает, что request.query.person
- это string
, поскольку express-validator
запускается во время выполнения, а система типов TS обладает информацией лишь о статических типах, предоставленных Express.
Но благодаря функции validateQuery
, TS знает, что person
является строкой.
Вот как можно использовать ее в коде:
// temp-validate-query-example.ts
import express from 'express';
import { z } from 'zod';
import { validateQuery } from '../middleware/validate';
const app = express();
// Определяем схему Zod для параметров поисковой строки
const helloQuerySchema = z.object({
person: z.string().min(1, { message: 'person is required' }),
});
app.get('/hello', async (request, response, next) => {
try {
// Валидируем и парсим `query` с помощью нашего кастомного валидатора
const query = await validateQuery(helloQuerySchema, request, response);
// TS теперь знает, что `query.person` - это `string`
response.send(`Hello, ${query.person}!`);
} catch (error) {
// Правильно обрабатываем ошибки (ошибки валидации уже отправлены клиенту)
next(error);
}
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
❯ Куки
Еще одна вещь, которую должен уметь делать сервер - это читать куки (cookie). По умолчанию Express умеет добавлять куки в ответы, но читать их из запросов не может.
Устанавливаем соответствующего посредника:
npm i cookie-parser && npm i -D @types/cookie-parser
Добавляем его в приложение:
// 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();
app.use(express.json());
// Посредник для чтения куки, содержащихся в запросе
app.use(cookieParser());
app.use('/api/v1', apiV1Router);
return app;
}
Теперь любой запрос будет иметь объект request.cookies
, содержащий куки, отправленные клиентом.
На этом первая часть туториала завершена. До встречи в следующей части.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩