Home » Как построить REST API с Prisma и PostgreSQL
Как построить REST API с Prisma и PostgreSQL

Как построить REST API с Prisma и PostgreSQL

Если ты работаешь с бэкендом и думаешь о том, как быстро поднять современный REST API с мощной базой данных, то эта статья для тебя. Мы разберём создание полноценного API с использованием Prisma ORM и PostgreSQL — комбинация, которая покрывает большинство потребностей современных веб-приложений. Здесь ты найдёшь практические команды, конфигурации и готовые примеры кода, которые помогут тебе развернуть всё с нуля на собственном сервере.

Сегодня мы пройдём путь от установки зависимостей до создания рабочего API с аутентификацией, валидацией данных и оптимизированными запросами. Разберём типичные подводные камни и дадим рекомендации по масштабированию. Если у тебя есть VPS или выделенный сервер, то через час-два у тебя будет готовый к продакшену REST API.

Что такое Prisma и зачем она нужна

Prisma — это современная ORM для Node.js и TypeScript, которая генерирует type-safe клиент для работы с базой данных. В отличие от классических ORM типа Sequelize или TypeORM, Prisma работает через схему (schema), которая является единым источником правды для структуры твоей базы данных.

Основные преимущества Prisma:

  • Type Safety — автоматическая генерация типов TypeScript
  • Prisma Studio — встроенный GUI для работы с данными
  • Мощные миграции — автоматическое создание и применение миграций
  • Отличная производительность — оптимизированные запросы с поддержкой JOIN
  • Introspection — возможность создать схему из существующей базы

Подготовка окружения

Перед началом работы убедись, что у тебя установлены Node.js (версия 16+) и PostgreSQL. Если работаешь на удалённом сервере, то сначала обнови систему и установи необходимые пакеты:

# Обновление системы (Ubuntu/Debian)
sudo apt update && sudo apt upgrade -y

# Установка Node.js через NodeSource
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs

# Установка PostgreSQL
sudo apt install postgresql postgresql-contrib -y

# Создание базы данных и пользователя
sudo -u postgres psql
postgres=# CREATE DATABASE myapi_db;
postgres=# CREATE USER myapi_user WITH PASSWORD 'strong_password_123';
postgres=# GRANT ALL PRIVILEGES ON DATABASE myapi_db TO myapi_user;
postgres=# \q

Инициализация проекта и установка зависимостей

Создаём новый проект и устанавливаем всё необходимое:

# Создание проекта
mkdir my-rest-api && cd my-rest-api
npm init -y

# Установка основных зависимостей
npm install express cors helmet morgan dotenv bcryptjs jsonwebtoken
npm install @prisma/client prisma

# Установка dev-зависимостей
npm install -D typescript @types/node @types/express @types/cors @types/bcryptjs @types/jsonwebtoken
npm install -D nodemon ts-node

# Инициализация TypeScript
npx tsc --init

# Инициализация Prisma
npx prisma init

Настройка схемы базы данных

После инициализации Prisma создаст файл prisma/schema.prisma. Настрой его под свои нужды:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  password  String
  name      String?
  role      Role     @default(USER)
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("users")
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("posts")
}

enum Role {
  USER
  ADMIN
}

Создай файл .env с настройками подключения к базе:

# .env
DATABASE_URL="postgresql://myapi_user:strong_password_123@localhost:5432/myapi_db"
JWT_SECRET="your-super-secret-jwt-key-here"
PORT=3000

Создание и применение миграций

Генерируем миграцию и применяем её к базе данных:

# Создание миграции
npx prisma migrate dev --name init

# Генерация Prisma Client
npx prisma generate

Эти команды создадут SQL-файлы миграций в папке prisma/migrations и сгенерируют type-safe клиент для работы с базой.

Структура проекта и базовая настройка

Создаём структуру проекта:

mkdir src src/routes src/middleware src/controllers src/utils
touch src/app.ts src/server.ts src/types.ts

Настраиваем основной файл приложения src/app.ts:

// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import dotenv from 'dotenv';

dotenv.config();

const app = express();

// Middleware
app.use(helmet());
app.use(cors());
app.use(morgan('combined'));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// Routes
app.get('/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date().toISOString() });
});

// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

export default app;

Создаём файл запуска сервера src/server.ts:

// src/server.ts
import app from './app';

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`🚀 Server is running on port ${PORT}`);
  console.log(`📊 Health check: http://localhost:${PORT}/health`);
});

Создание middleware для аутентификации

Создаём middleware для работы с JWT токенами:

// src/middleware/auth.ts
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

interface AuthRequest extends Request {
  user?: { id: string; email: string; role: string };
}

export const authenticateToken = (req: AuthRequest, res: Response, next: NextFunction) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  jwt.verify(token, process.env.JWT_SECRET as string, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid token' });
    }
    req.user = user as { id: string; email: string; role: string };
    next();
  });
};

export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction) => {
  if (req.user?.role !== 'ADMIN') {
    return res.status(403).json({ error: 'Admin access required' });
  }
  next();
};

Создание контроллеров

Создаём контроллер для аутентификации:

// src/controllers/auth.ts
import { Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

const prisma = new PrismaClient();

export const register = async (req: Request, res: Response) => {
  try {
    const { email, password, name } = req.body;

    // Валидация
    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required' });
    }

    // Проверка существования пользователя
    const existingUser = await prisma.user.findUnique({
      where: { email }
    });

    if (existingUser) {
      return res.status(400).json({ error: 'User already exists' });
    }

    // Хеширование пароля
    const hashedPassword = await bcrypt.hash(password, 10);

    // Создание пользователя
    const user = await prisma.user.create({
      data: {
        email,
        password: hashedPassword,
        name
      },
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
        createdAt: true
      }
    });

    // Создание JWT токена
    const token = jwt.sign(
      { id: user.id, email: user.email, role: user.role },
      process.env.JWT_SECRET as string,
      { expiresIn: '24h' }
    );

    res.status(201).json({ user, token });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

export const login = async (req: Request, res: Response) => {
  try {
    const { email, password } = req.body;

    // Поиск пользователя
    const user = await prisma.user.findUnique({
      where: { email }
    });

    if (!user) {
      return res.status(400).json({ error: 'Invalid credentials' });
    }

    // Проверка пароля
    const isValidPassword = await bcrypt.compare(password, user.password);

    if (!isValidPassword) {
      return res.status(400).json({ error: 'Invalid credentials' });
    }

    // Создание JWT токена
    const token = jwt.sign(
      { id: user.id, email: user.email, role: user.role },
      process.env.JWT_SECRET as string,
      { expiresIn: '24h' }
    );

    res.json({ 
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      },
      token 
    });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

Создаём контроллер для постов:

// src/controllers/posts.ts
import { Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

interface AuthRequest extends Request {
  user?: { id: string; email: string; role: string };
}

export const getPosts = async (req: Request, res: Response) => {
  try {
    const { page = 1, limit = 10, published } = req.query;
    const skip = (Number(page) - 1) * Number(limit);

    const where = published === 'true' ? { published: true } : {};

    const posts = await prisma.post.findMany({
      where,
      skip,
      take: Number(limit),
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true
          }
        }
      },
      orderBy: {
        createdAt: 'desc'
      }
    });

    const total = await prisma.post.count({ where });

    res.json({
      posts,
      pagination: {
        page: Number(page),
        limit: Number(limit),
        total,
        pages: Math.ceil(total / Number(limit))
      }
    });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

export const createPost = async (req: AuthRequest, res: Response) => {
  try {
    const { title, content, published = false } = req.body;

    if (!title) {
      return res.status(400).json({ error: 'Title is required' });
    }

    const post = await prisma.post.create({
      data: {
        title,
        content,
        published,
        authorId: req.user!.id
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true
          }
        }
      }
    });

    res.status(201).json(post);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

export const updatePost = async (req: AuthRequest, res: Response) => {
  try {
    const { id } = req.params;
    const { title, content, published } = req.body;

    // Проверка существования поста
    const existingPost = await prisma.post.findUnique({
      where: { id }
    });

    if (!existingPost) {
      return res.status(404).json({ error: 'Post not found' });
    }

    // Проверка прав доступа
    if (existingPost.authorId !== req.user!.id && req.user!.role !== 'ADMIN') {
      return res.status(403).json({ error: 'Access denied' });
    }

    const post = await prisma.post.update({
      where: { id },
      data: {
        ...(title && { title }),
        ...(content !== undefined && { content }),
        ...(published !== undefined && { published })
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true
          }
        }
      }
    });

    res.json(post);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

export const deletePost = async (req: AuthRequest, res: Response) => {
  try {
    const { id } = req.params;

    const existingPost = await prisma.post.findUnique({
      where: { id }
    });

    if (!existingPost) {
      return res.status(404).json({ error: 'Post not found' });
    }

    if (existingPost.authorId !== req.user!.id && req.user!.role !== 'ADMIN') {
      return res.status(403).json({ error: 'Access denied' });
    }

    await prisma.post.delete({
      where: { id }
    });

    res.status(204).send();
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

Создание маршрутов

Создаём файлы маршрутов:

// src/routes/auth.ts
import { Router } from 'express';
import { register, login } from '../controllers/auth';

const router = Router();

router.post('/register', register);
router.post('/login', login);

export default router;
// src/routes/posts.ts
import { Router } from 'express';
import { getPosts, createPost, updatePost, deletePost } from '../controllers/posts';
import { authenticateToken } from '../middleware/auth';

const router = Router();

router.get('/', getPosts);
router.post('/', authenticateToken, createPost);
router.put('/:id', authenticateToken, updatePost);
router.delete('/:id', authenticateToken, deletePost);

export default router;

Подключаем маршруты в основное приложение. Обновляем src/app.ts:

// src/app.ts (добавляем после существующего кода)
import authRoutes from './routes/auth';
import postsRoutes from './routes/posts';

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/posts', postsRoutes);

app.get('/health', (req, res) => {
  res.json({ status: 'OK', timestamp: new Date().toISOString() });
});

Настройка скриптов для запуска

Обновляем package.json:

{
  "scripts": {
    "dev": "nodemon src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "db:migrate": "npx prisma migrate dev",
    "db:generate": "npx prisma generate",
    "db:studio": "npx prisma studio",
    "db:reset": "npx prisma migrate reset",
    "db:seed": "ts-node prisma/seed.ts"
  }
}

Тестирование API

Запускаем сервер для разработки:

npm run dev

Теперь можно тестировать API с помощью curl или любого HTTP-клиента:

# Регистрация пользователя
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","name":"Test User"}'

# Вход в систему
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123"}'

# Создание поста (требуется токен)
curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -d '{"title":"My First Post","content":"This is the content","published":true}'

# Получение списка постов
curl http://localhost:3000/api/posts?page=1&limit=10&published=true

Сравнение с альтернативными решениями

Решение Преимущества Недостатки Подходит для
Prisma + PostgreSQL Type safety, отличная производительность, современный подход Относительно новая технология, меньше примеров Новые проекты, TypeScript-приложения
Sequelize + PostgreSQL Зрелость, большое сообщество, много примеров Отсутствие type safety, сложный синтаксис Легаси-проекты, JavaScript-приложения
TypeORM + PostgreSQL Декораторы, поддержка TypeScript из коробки Медленная работа, сложная настройка Enterprise-приложения с сложной логикой
Mongoose + MongoDB Гибкость схемы, быстрая разработка Отсутствие ACID-транзакций, сложность в масштабировании Прототипы, MVP, контент-сайты

Оптимизация и продакшн-готовность

Для подготовки к продакшену добавь следующие улучшения:

Создание seed-файла для тестовых данных

// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';

const prisma = new PrismaClient();

async function main() {
  // Создание тестового пользователя-админа
  const hashedPassword = await bcrypt.hash('admin123', 10);
  
  const admin = await prisma.user.upsert({
    where: { email: 'admin@example.com' },
    update: {},
    create: {
      email: 'admin@example.com',
      password: hashedPassword,
      name: 'Admin User',
      role: 'ADMIN'
    }
  });

  // Создание тестовых постов
  const posts = await Promise.all([
    prisma.post.create({
      data: {
        title: 'Getting Started with Prisma',
        content: 'Prisma is a next-generation ORM for Node.js and TypeScript.',
        published: true,
        authorId: admin.id
      }
    }),
    prisma.post.create({
      data: {
        title: 'Advanced PostgreSQL Features',
        content: 'PostgreSQL offers many advanced features for modern applications.',
        published: true,
        authorId: admin.id
      }
    })
  ]);

  console.log('Seed data created:', { admin, posts });
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

Настройка логирования

// src/utils/logger.ts
import winston from 'winston';

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'rest-api' },
  transports: [
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/combined.log' })
  ]
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }));
}

export default logger;

Настройка rate limiting

// src/middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';

export const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 минут
  max: 100, // максимум 100 запросов с IP
  message: 'Too many requests from this IP, please try again later.',
  standardHeaders: true,
  legacyHeaders: false
});

export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // максимум 5 попыток входа
  message: 'Too many authentication attempts, please try again later.',
  skipSuccessfulRequests: true
});

Продвинутые возможности

Полнотекстовый поиск

PostgreSQL поддерживает полнотекстовый поиск. Обновим модель Post:

// Добавляем в schema.prisma
model Post {
  // ... существующие поля
  searchVector Unsupported("tsvector")?

  @@index([searchVector], type: Tsv)
}

Создаём функцию поиска:

// src/controllers/search.ts
export const searchPosts = async (req: Request, res: Response) => {
  try {
    const { query } = req.query;
    
    if (!query) {
      return res.status(400).json({ error: 'Search query is required' });
    }

    const posts = await prisma.$queryRaw`
      SELECT p.*, u.name as author_name, u.email as author_email
      FROM posts p
      JOIN users u ON p."authorId" = u.id
      WHERE to_tsvector('english', p.title || ' ' || COALESCE(p.content, '')) 
            @@ plainto_tsquery('english', ${query})
      ORDER BY ts_rank(to_tsvector('english', p.title || ' ' || COALESCE(p.content, '')), 
                      plainto_tsquery('english', ${query})) DESC
      LIMIT 20;
    `;

    res.json(posts);
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Search failed' });
  }
};

Кеширование с Redis

// src/middleware/cache.ts
import redis from 'redis';

const client = redis.createClient({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379')
});

export const cacheMiddleware = (duration: number = 300) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = `cache:${req.originalUrl}`;
    
    try {
      const cached = await client.get(key);
      
      if (cached) {
        return res.json(JSON.parse(cached));
      }
      
      const originalSend = res.json;
      res.json = function(data) {
        client.setex(key, duration, JSON.stringify(data));
        return originalSend.call(this, data);
      };
      
      next();
    } catch (error) {
      console.error('Cache error:', error);
      next();
    }
  };
};

Мониторинг и метрики

Для мониторинга в продакшене добавь middleware для сбора метрик:

// src/middleware/metrics.ts
import prometheus from 'prom-client';

const httpRequestDuration = new prometheus.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status_code']
});

const httpRequestTotal = new prometheus.Counter({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route', 'status_code']
});

export const metricsMiddleware = (req: Request, res: Response, next: NextFunction) => {
  const start = Date.now();
  
  res.on('finish', () => {
    const duration = (Date.now() - start) / 1000;
    const labels = {
      method: req.method,
      route: req.route?.path || req.path,
      status_code: res.statusCode.toString()
    };
    
    httpRequestDuration.observe(labels, duration);
    httpRequestTotal.inc(labels);
  });
  
  next();
};

export const getMetrics = async (req: Request, res: Response) => {
  res.set('Content-Type', prometheus.register.contentType);
  res.end(await prometheus.register.metrics());
};

Интересные возможности для автоматизации

Prisma открывает интересные возможности для автоматизации:

  • Автоматическая генерация документации API — на основе схемы Prisma можно генерировать OpenAPI спецификацию
  • Автоматические тесты — type-safe клиент упрощает написание unit и integration тестов
  • Автоматическое создание GraphQL API — с помощью Nexus или TypeGraphQL
  • Автоматическое создание админки — с помощью AdminJS или собственного решения
  • Автоматическая валидация — интеграция с Zod для runtime валидации

Развёртывание и Docker

Создай Dockerfile для контейнеризации:

# Dockerfile
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci --only=production && npm cache clean --force

COPY . .
RUN npm run build
RUN npx prisma generate

FROM node:18-alpine AS runner

WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package*.json ./

EXPOSE 3000

CMD ["npm", "start"]

И docker-compose.yml для локальной разработки:

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    depends_on:
      - postgres
      - redis
    environment:
      - DATABASE_URL=postgresql://user:password@postgres:5432/myapi
      - REDIS_HOST=redis
      - NODE_ENV=production
    volumes:
      - ./logs:/app/logs

  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=myapi
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

Заключение и рекомендации

Связка Prisma + PostgreSQL представляет собой мощное решение для современных REST API. Основные преимущества этого подхода:

  • Высокая производительность — оптимизированные запросы и connection pooling
  • Type Safety — полная безопасность типов на этапе компиляции
  • Простота разработки — автоматическая генерация клиента и миграций
  • Масштабируемость — PostgreSQL отлично справляется с высокими нагрузками
  • Богатая экосистема — множество расширений и инструментов

Рекомендую использовать это решение для:

  • Новых проектов с TypeScript
  • API с сложными отношениями в данных
  • Проектов, где важна безопасность типов
  • Приложений с высокими требованиями к производительности

Не стоит выбирать Prisma для:

  • Простых CRUD-приложений без сложной логики
  • Легаси-проектов на JavaScript без планов миграции на TypeScript
  • Проектов с экстремально высокими требованиями к производительности (где нужен raw SQL)

Для продакшена обязательно настрой мониторинг, логирование, кеширование и резервное копирование. Prisma Studio поможет в отладке и анализе данных, а система миграций упростит развёртывание обновлений.

При правильной настройке этот стек обеспечит тебе быструю разработку и надёжную работу API в продакшене. Удачи в создании твоего следующего проекта!


В этой статье собрана информация и материалы из различных интернет-источников. Мы признаем и ценим работу всех оригинальных авторов, издателей и веб-сайтов. Несмотря на то, что были приложены все усилия для надлежащего указания исходного материала, любая непреднамеренная оплошность или упущение не являются нарушением авторских прав. Все упомянутые товарные знаки, логотипы и изображения являются собственностью соответствующих владельцев. Если вы считаете, что какой-либо контент, использованный в этой статье, нарушает ваши авторские права, немедленно свяжитесь с нами для рассмотрения и принятия оперативных мер.

Данная статья предназначена исключительно для ознакомительных и образовательных целей и не ущемляет права правообладателей. Если какой-либо материал, защищенный авторским правом, был использован без должного упоминания или с нарушением законов об авторском праве, это непреднамеренно, и мы исправим это незамедлительно после уведомления. Обратите внимание, что переиздание, распространение или воспроизведение части или всего содержимого в любой форме запрещено без письменного разрешения автора и владельца веб-сайта. Для получения разрешений или дополнительных запросов, пожалуйста, свяжитесь с нами.

Leave a reply

Your email address will not be published. Required fields are marked