- Home »

Как построить 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 в продакшене. Удачи в создании твоего следующего проекта!
В этой статье собрана информация и материалы из различных интернет-источников. Мы признаем и ценим работу всех оригинальных авторов, издателей и веб-сайтов. Несмотря на то, что были приложены все усилия для надлежащего указания исходного материала, любая непреднамеренная оплошность или упущение не являются нарушением авторских прав. Все упомянутые товарные знаки, логотипы и изображения являются собственностью соответствующих владельцев. Если вы считаете, что какой-либо контент, использованный в этой статье, нарушает ваши авторские права, немедленно свяжитесь с нами для рассмотрения и принятия оперативных мер.
Данная статья предназначена исключительно для ознакомительных и образовательных целей и не ущемляет права правообладателей. Если какой-либо материал, защищенный авторским правом, был использован без должного упоминания или с нарушением законов об авторском праве, это непреднамеренно, и мы исправим это незамедлительно после уведомления. Обратите внимание, что переиздание, распространение или воспроизведение части или всего содержимого в любой форме запрещено без письменного разрешения автора и владельца веб-сайта. Для получения разрешений или дополнительных запросов, пожалуйста, свяжитесь с нами.