О насБлогКонтакты
Бэкенд-разработка30 апреля 2026 г. 11 мин 56

REST API на Node.js + TypeScript: полный production-гайд 2026

AunimedaAunimeda
📋 Содержание

REST API на Node.js + TypeScript: полный production-гайд 2026

Node.js + TypeScript - стандартный стек для backend в 2026 году. Эта статья - не "hello world". Здесь полный production-код: правильная архитектура, безопасность, валидация, аутентификация и Docker-деплой.


Инициализация проекта

mkdir api && cd api
npm init -y

npm install express @types/express
npm install -D typescript ts-node nodemon @types/node

npx tsc --init

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
npm install pg zod bcryptjs jsonwebtoken dotenv
npm install -D @types/pg @types/bcryptjs @types/jsonwebtoken

npm install express-rate-limit helmet cors
npm install -D @types/cors

Типизированные переменные окружения

Самая частая ошибка - доступ к process.env напрямую без валидации. Это ломается в runtime, а не во время компиляции.

// src/config/env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.string().transform(Number).default('3000'),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default('7d'),
  BCRYPT_ROUNDS: z.string().transform(Number).default('12'),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('❌ Ошибки в переменных окружения:');
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

Подключение к PostgreSQL

// src/config/db.ts
import { Pool, PoolClient } from 'pg';
import { env } from './env';

export const pool = new Pool({
  connectionString: env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});

pool.on('error', (err) => {
  console.error('Неожиданная ошибка PostgreSQL:', err);
  process.exit(-1);
});

// Хелпер для транзакций
export async function withTransaction<T>(
  callback: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const result = await callback(client);
    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

Кастомные классы ошибок

// src/errors/AppError.ts
export class AppError extends Error {
  constructor(
    public readonly message: string,
    public readonly statusCode: number,
    public readonly code?: string
  ) {
    super(message);
    this.name = 'AppError';
    Error.captureStackTrace(this, this.constructor);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} не найден`, 404, 'NOT_FOUND');
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = 'Не авторизован') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

export class ValidationError extends AppError {
  constructor(
    message: string,
    public readonly details?: unknown
  ) {
    super(message, 400, 'VALIDATION_ERROR');
  }
}

export class ConflictError extends AppError {
  constructor(message: string) {
    super(message, 409, 'CONFLICT');
  }
}

Middleware: валидация через Zod

// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';

type ValidationTarget = 'body' | 'query' | 'params';

export function validate(schema: ZodSchema, target: ValidationTarget = 'body') {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req[target]);

    if (!result.success) {
      return res.status(400).json({
        success: false,
        error: 'Ошибка валидации',
        details: result.error.flatten().fieldErrors,
      });
    }

    // Заменяем данные запроса на валидированные/типизированные
    req[target] = result.data;
    next();
  };
}

Аутентификация: JWT + bcrypt

// src/services/auth.service.ts
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { pool } from '../config/db';
import { env } from '../config/env';
import { UnauthorizedError, NotFoundError, ConflictError } from '../errors/AppError';

interface User {
  id: string;
  email: string;
  name: string;
  role: string;
  created_at: Date;
}

interface TokenPayload {
  userId: string;
  email: string;
  role: string;
}

export async function register(email: string, password: string, name: string): Promise<User> {
  // Проверяем, не занят ли email
  const existing = await pool.query(
    'SELECT id FROM users WHERE email = $1',
    [email.toLowerCase()]
  );
  if (existing.rows.length > 0) {
    throw new ConflictError('Пользователь с таким email уже существует');
  }

  const passwordHash = await bcrypt.hash(password, env.BCRYPT_ROUNDS);

  const { rows } = await pool.query<User>(
    `INSERT INTO users (email, password_hash, name)
     VALUES ($1, $2, $3)
     RETURNING id, email, name, role, created_at`,
    [email.toLowerCase(), passwordHash, name]
  );

  return rows[0];
}

export async function login(email: string, password: string) {
  const { rows } = await pool.query<User & { password_hash: string }>(
    'SELECT id, email, name, role, password_hash, created_at FROM users WHERE email = $1',
    [email.toLowerCase()]
  );

  const user = rows[0];

  // Защита от timing-атак: всегда вызываем bcrypt.compare, даже если пользователь не найден
  const dummyHash = '$2b$12$invalidhashfortimingprotection00000000000000000000000';
  const passwordMatch = await bcrypt.compare(password, user?.password_hash ?? dummyHash);

  if (!user || !passwordMatch) {
    throw new UnauthorizedError('Неверный email или пароль');
  }

  const accessToken = jwt.sign(
    { userId: user.id, email: user.email, role: user.role } as TokenPayload,
    env.JWT_SECRET,
    { expiresIn: env.JWT_EXPIRES_IN } as jwt.SignOptions
  );

  const { password_hash: _, ...userWithoutPassword } = user;
  return { user: userWithoutPassword, accessToken };
}

export function verifyToken(token: string): TokenPayload {
  try {
    return jwt.verify(token, env.JWT_SECRET) as TokenPayload;
  } catch {
    throw new UnauthorizedError('Токен недействителен или истёк');
  }
}

Middleware для проверки токена:

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../services/auth.service';
import { UnauthorizedError } from '../errors/AppError';

export interface AuthenticatedRequest extends Request {
  user: {
    userId: string;
    email: string;
    role: string;
  };
}

export function authenticate(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return next(new UnauthorizedError('Токен не предоставлен'));
  }

  const token = authHeader.slice(7);
  const payload = verifyToken(token);
  (req as AuthenticatedRequest).user = payload;
  next();
}

// Middleware для проверки роли
export function requireRole(...roles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const { user } = req as AuthenticatedRequest;
    if (!roles.includes(user.role)) {
      return res.status(403).json({
        success: false,
        error: 'Недостаточно прав',
      });
    }
    next();
  };
}

Паттерн Repository

// src/repositories/user.repository.ts
import { PoolClient } from 'pg';
import { pool } from '../config/db';
import { NotFoundError } from '../errors/AppError';

export interface CreateUserDto {
  email: string;
  passwordHash: string;
  name: string;
}

export interface UpdateUserDto {
  name?: string;
  email?: string;
}

export interface User {
  id: string;
  email: string;
  name: string;
  role: string;
  created_at: Date;
  updated_at: Date;
}

export class UserRepository {
  private client: typeof pool | PoolClient;

  constructor(client?: PoolClient) {
    this.client = client ?? pool;
  }

  async findById(id: string): Promise<User> {
    const { rows } = await this.client.query<User>(
      'SELECT id, email, name, role, created_at, updated_at FROM users WHERE id = $1',
      [id]
    );
    if (!rows[0]) throw new NotFoundError('Пользователь');
    return rows[0];
  }

  async findByEmail(email: string): Promise<User | null> {
    const { rows } = await this.client.query<User>(
      'SELECT id, email, name, role, created_at, updated_at FROM users WHERE email = $1',
      [email.toLowerCase()]
    );
    return rows[0] ?? null;
  }

  async update(id: string, data: UpdateUserDto): Promise<User> {
    const setClauses: string[] = [];
    const values: unknown[] = [];
    let paramIdx = 1;

    if (data.name !== undefined) {
      setClauses.push(`name = $${paramIdx++}`);
      values.push(data.name);
    }
    if (data.email !== undefined) {
      setClauses.push(`email = $${paramIdx++}`);
      values.push(data.email.toLowerCase());
    }

    if (setClauses.length === 0) {
      return this.findById(id);
    }

    setClauses.push(`updated_at = now()`);
    values.push(id);

    const { rows } = await this.client.query<User>(
      `UPDATE users SET ${setClauses.join(', ')}
       WHERE id = $${paramIdx}
       RETURNING id, email, name, role, created_at, updated_at`,
      values
    );

    if (!rows[0]) throw new NotFoundError('Пользователь');
    return rows[0];
  }

  async list(limit = 20, offset = 0): Promise<{ users: User[]; total: number }> {
    const [usersResult, countResult] = await Promise.all([
      this.client.query<User>(
        'SELECT id, email, name, role, created_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2',
        [limit, offset]
      ),
      this.client.query<{ count: string }>(
        'SELECT COUNT(*)::text FROM users'
      ),
    ]);

    return {
      users: usersResult.rows,
      total: parseInt(countResult.rows[0].count, 10),
    };
  }
}

Роуты и контроллеры

// src/routes/auth.routes.ts
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate';
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
import * as authService from '../services/auth.service';
import { UserRepository } from '../repositories/user.repository';

export const authRouter = Router();

const registerSchema = z.object({
  email: z.string().email('Некорректный email'),
  password: z.string().min(8, 'Пароль минимум 8 символов'),
  name: z.string().min(2, 'Имя минимум 2 символа').max(100),
});

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

authRouter.post('/register', validate(registerSchema), async (req, res, next) => {
  try {
    const user = await authService.register(
      req.body.email,
      req.body.password,
      req.body.name
    );
    res.status(201).json({ success: true, data: { user } });
  } catch (err) {
    next(err);
  }
});

authRouter.post('/login', validate(loginSchema), async (req, res, next) => {
  try {
    const result = await authService.login(req.body.email, req.body.password);
    res.json({ success: true, data: result });
  } catch (err) {
    next(err);
  }
});

authRouter.get('/me', authenticate, async (req, res, next) => {
  try {
    const { userId } = (req as AuthenticatedRequest).user;
    const repo = new UserRepository();
    const user = await repo.findById(userId);
    res.json({ success: true, data: { user } });
  } catch (err) {
    next(err);
  }
});

Rate Limiting через Redis

// src/middleware/rateLimiter.ts
import rateLimit from 'express-rate-limit';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);

// Строгий лимит для аутентификации (защита от brute force)
export const authRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 минут
  max: 10,                    // максимум 10 попыток
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    success: false,
    error: 'Слишком много попыток. Подождите 15 минут.',
  },
  // Ключ по IP + email для более точного ограничения
  keyGenerator: (req) => `${req.ip}:${req.body?.email ?? 'unknown'}`,
});

// Общий лимит для API
export const apiRateLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 минута
  max: 100,
  standardHeaders: true,
  message: {
    success: false,
    error: 'Превышен лимит запросов',
  },
});

Глобальный обработчик ошибок

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
import { AppError } from '../errors/AppError';
import { env } from '../config/env';

export function errorHandler(
  error: unknown,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  // Известные ошибки приложения
  if (error instanceof AppError) {
    return res.status(error.statusCode).json({
      success: false,
      error: error.message,
      code: error.code,
    });
  }

  // Ошибки PostgreSQL
  if (
    error instanceof Error &&
    'code' in error &&
    typeof (error as any).code === 'string'
  ) {
    const pgCode = (error as any).code;
    if (pgCode === '23505') {
      return res.status(409).json({ success: false, error: 'Запись уже существует' });
    }
    if (pgCode === '23503') {
      return res.status(400).json({ success: false, error: 'Связанная запись не найдена' });
    }
  }

  // Неожиданные ошибки
  console.error('Необработанная ошибка:', error);

  return res.status(500).json({
    success: false,
    error: 'Внутренняя ошибка сервера',
    // В разработке показываем stack trace
    ...(env.NODE_ENV === 'development' && error instanceof Error
      ? { stack: error.stack }
      : {}),
  });
}

Сборка приложения

// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { authRouter } from './routes/auth.routes';
import { apiRateLimiter } from './middleware/rateLimiter';
import { errorHandler } from './middleware/errorHandler';

const app = express();

// Безопасность
app.use(helmet());
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*',
  credentials: true,
}));

// Парсинг тела запроса
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// Rate limiting
app.use('/api', apiRateLimiter);

// Healthcheck - без rate limit, используется для Docker/k8s
app.get('/health', (_, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Роуты
app.use('/api/auth', authRouter);

// 404
app.use('*', (_, res) => {
  res.status(404).json({ success: false, error: 'Маршрут не найден' });
});

// Обработчик ошибок - должен быть последним
app.use(errorHandler);

export { app };
// src/index.ts
import { app } from './app';
import { pool } from './config/db';
import { env } from './config/env';

async function start() {
  // Проверяем подключение к базе данных
  await pool.query('SELECT 1');
  console.log('✅ Подключение к PostgreSQL установлено');

  const server = app.listen(env.PORT, () => {
    console.log(`🚀 Сервер запущен на порту ${env.PORT} [${env.NODE_ENV}]`);
  });

  // Graceful shutdown
  process.on('SIGTERM', async () => {
    console.log('SIGTERM получен, завершаем работу...');
    server.close(async () => {
      await pool.end();
      console.log('Сервер остановлен');
      process.exit(0);
    });
  });
}

start().catch((err) => {
  console.error('Ошибка запуска:', err);
  process.exit(1);
});

Docker: мультистадийная сборка

# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && cp -r node_modules /tmp/prod_modules
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build  # tsc → dist/

FROM node:22-alpine AS runner
WORKDIR /app

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nodeapp

COPY --from=builder --chown=nodeapp:nodejs /app/dist ./dist
COPY --from=deps --chown=nodeapp:nodejs /tmp/prod_modules ./node_modules
COPY --chown=nodeapp:nodejs package*.json ./

USER nodeapp
ENV NODE_ENV=production
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/index.js"]
# docker-compose.yml (для локальной разработки)
version: '3.9'
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://dev:dev@postgres:5432/devdb
      JWT_SECRET: dev-secret-at-least-32-characters-long
      NODE_ENV: development
    volumes:
      - ./src:/app/src  # hot reload через nodemon
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: devdb
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dev"]
      interval: 5s
      retries: 5

Структура проекта

src/
├── config/
│   ├── db.ts           # подключение к PostgreSQL
│   └── env.ts          # типизированные переменные окружения
├── errors/
│   └── AppError.ts     # классы ошибок
├── middleware/
│   ├── auth.ts         # JWT-аутентификация
│   ├── errorHandler.ts # глобальный обработчик ошибок
│   ├── rateLimiter.ts  # rate limiting
│   └── validate.ts     # Zod-валидация
├── repositories/
│   └── user.repository.ts
├── routes/
│   └── auth.routes.ts
├── services/
│   └── auth.service.ts
├── app.ts              # Express-приложение
└── index.ts            # точка входа

Aunimeda разрабатывает production-ready backend API на Node.js и TypeScript для веб и мобильных приложений.

Обсудить проект. Смотрите также: Разработка ПО, Разработка сайтов, DevOps

Читайте также

Как разработать мобильное приложение с нуля в 2026 годуaunimeda
Мобильная разработка

Как разработать мобильное приложение с нуля в 2026 году

Полный процесс разработки мобильного приложения: от идеи до публикации в App Store и Google Play. Выбор технологий, этапы разработки, типичные ошибки и реальные сроки.

Как выбрать технологический стек для стартапа в 2026 годуaunimeda
Веб-разработка

Как выбрать технологический стек для стартапа в 2026 году

React, Vue, Next.js, Node.js, Python, PostgreSQL, MongoDB - как не потратить 3 месяца на выбор стека и сделать правильно с первого раза. Реальные рекомендации для стартапов разных типов.

Как создать Telegram Mini App для бизнеса в 2026 годуaunimeda
CRM и автоматизация

Как создать Telegram Mini App для бизнеса в 2026 году

Telegram Mini App - это полноценное веб-приложение внутри Telegram. Никаких установок, никаких аккаунтов - клиент просто нажимает кнопку и видит ваш магазин/каталог/форму бронирования.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Кыргызстане. Бесплатная консультация.

Разработка Telegram-ботов

Получить консультацию Все статьи