Sumário
- Introdução: O que é uma API REST e por que Node.js?
- Pré-requisitos e Configuração do Ambiente
- Estrutura do Projeto: Arquitetura Profissional
- Configurando Express e Middlewares Essenciais
- Modelagem de Dados com PostgreSQL e Prisma ORM
- Implementando Autenticação e Autorização com JWT
- CRUD Completo: Controllers, Services e Repositories
- Validação de Dados com Zod
- Tratamento de Erros Profissional
- Segurança: Rate Limiting, CORS, Helmet e mais
- Testes Automatizados: Unitários e de Integração
- Documentação com Swagger/OpenAPI
- Logging e Monitoramento
- Deploy em Produção: Docker e Cloud
- Boas Práticas e Padrões de Projeto
- Conclusão
1. Introdução: O que é uma API REST e por que Node.js? {#introducao}
Uma API REST (Representational State Transfer) é uma interface de comunicação entre sistemas que segue um conjunto de princípios arquiteturais definidos por Roy Fielding em sua dissertação de doutorado no ano 2000. Desde então, REST se tornou o padrão dominante para construção de APIs web, e por um bom motivo: é simples, escalável e stateless.
No ecossistema atual de desenvolvimento de software, as APIs REST são a espinha dorsal de praticamente toda aplicação moderna. Desde aplicativos móveis até sistemas distribuídos em microsserviços, a comunicação via HTTP/REST está em todo lugar. Um desenvolvedor backend que domina a criação de APIs REST robustas e seguras é extremamente valorizado no mercado.
Por que escolher Node.js para APIs REST?
Node.js surgiu em 2009 criado por Ryan Dahl como uma plataforma de execução JavaScript no servidor. Desde então, evoluiu para se tornar uma das tecnologias mais populares para desenvolvimento backend, e há razões sólidas para isso:
Performance Excepcional com I/O Assíncrono
O Node.js utiliza um modelo de I/O não-bloqueante e orientado a eventos. Isso significa que enquanto aguarda operações de I/O (leituras de banco de dados, chamadas a APIs externas, leitura de arquivos), o thread principal continua processando outras requisições. Para APIs REST, que são fundamentalmente I/O intensivas, isso resulta em performance excelente mesmo com recursos limitados.
JavaScript Full-Stack
Usar a mesma linguagem no frontend e backend elimina a fricção cognitiva de alternar entre paradigmas diferentes. Desenvolvedores JavaScript podem contribuir com o código backend sem uma curva de aprendizado acentuada.
Ecossistema npm Gigantesco
O npm possui mais de 2 milhões de pacotes. Para praticamente qualquer necessidade que você possa imaginar, existe uma biblioteca battle-tested disponível. Express, Fastify, Prisma, Mongoose, jsonwebtoken, bcrypt — o ecossistema Node.js tem tudo.
Empresas que Usam Node.js em Produção
Netflix, LinkedIn, Uber, PayPal, NASA e muitas outras empresas de grande porte utilizam Node.js em produção. O LinkedIn reduziu o número de servidores em 20x ao migrar de Ruby on Rails para Node.js. O PayPal reduziu em 35% o tempo médio de resposta. Esses números demonstram a capacidade real do Node.js em ambientes de alta demanda.
Os Princípios REST que Todo Desenvolvedor Precisa Dominar
Antes de começarmos a codificar, é essencial entender os seis princípios que definem uma arquitetura verdadeiramente RESTful:
1. Interface Uniforme (Uniform Interface)
Todos os recursos são identificados por URIs únicas. As ações sobre recursos são realizadas através dos métodos HTTP padrão (GET, POST, PUT, PATCH, DELETE). As respostas incluem metadados suficientes para o cliente processar o recurso.
2. Stateless (Sem Estado)
Cada requisição deve conter todas as informações necessárias para ser processada. O servidor não mantém estado de sessão do cliente entre requisições. Isso é fundamental para escalabilidade horizontal.
3. Cacheable (Cacheável)
As respostas devem indicar se podem ser armazenadas em cache. O uso correto de headers como Cache-Control, ETag e Last-Modified pode reduzir drasticamente a carga nos servidores.
4. Client-Server (Cliente-Servidor)
O cliente e o servidor são independentes. O cliente não precisa saber como o servidor armazena os dados, e o servidor não precisa saber como o cliente renderiza a interface.
5. Layered System (Sistema em Camadas)
O cliente não precisa saber se está comunicando diretamente com o servidor final ou através de intermediários (load balancers, caches, gateways).
6. Code on Demand (Código por Demanda) — Opcional
O servidor pode enviar código executável ao cliente, como JavaScript. Este é o único princípio opcional do REST.
2. Pré-requisitos e Configuração do Ambiente {#prerequisitos}
O que você precisa saber antes de começar
Este guia assume que você tem conhecimento básico de:
- JavaScript/TypeScript (ES2020+)
- Conceitos básicos de HTTP (métodos, status codes, headers)
- Fundamentos de banco de dados relacionais
- Linha de comando básica
Instalação do Ambiente de Desenvolvimento
Vamos configurar um ambiente profissional do zero. Utilizaremos TypeScript em vez de JavaScript puro, pois em projetos reais a tipagem estática previne toda uma classe de bugs e melhora a experiência de desenvolvimento.
Node.js via NVM (Node Version Manager)
É altamente recomendado usar o NVM para gerenciar versões do Node.js. Isso permite que você alterne entre versões facilmente:
# Instalar NVM (Linux/Mac)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
# Instalar Node.js LTS mais recente
nvm install --lts
nvm use --lts
# Verificar instalação
node --version # v20.x.x
npm --version # 10.x.x
Ferramentas Essenciais
# Instalar pnpm (gerenciador de pacotes mais eficiente)
npm install -g pnpm
# TypeScript globalmente (para uso via CLI)
npm install -g typescript ts-node
# Ferramentas de desenvolvimento
npm install -g nodemon
Editor de Código: VS Code com Extensões
Recomendamos VS Code com as seguintes extensões:
- ESLint
- Prettier
- Thunder Client (teste de APIs)
- Prisma
- GitLens
- Error Lens
Criando e Inicializando o Projeto
mkdir api-rest-nodejs-2025
cd api-rest-nodejs-2025
# Inicializar projeto com pnpm
pnpm init
# Instalar TypeScript e configurações iniciais
pnpm add -D typescript @types/node ts-node-dev
# Criar arquivo de configuração TypeScript
npx tsc --init
Configuração do tsconfig.json
Substitua o conteúdo gerado pelo seguinte, otimizado para Node.js:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Configuração do package.json com scripts úteis
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only --exit-child src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"format": "prettier --write src/**/*.ts",
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"db:seed": "ts-node prisma/seed.ts"
}
}
3. Estrutura do Projeto: Arquitetura Profissional {#estrutura}
A estrutura de um projeto Node.js profissional é fundamental para manutenibilidade, testabilidade e escalabilidade. Vamos usar uma arquitetura em camadas (layered architecture) com separação clara de responsabilidades:
api-rest-nodejs-2025/
├── src/
│ ├── config/
│ │ ├── database.ts # Configuração do banco de dados
│ │ ├── env.ts # Variáveis de ambiente
│ │ ├── swagger.ts # Configuração da documentação
│ │ └── redis.ts # Configuração do Redis (cache)
│ ├── modules/
│ │ ├── users/
│ │ │ ├── user.controller.ts
│ │ │ ├── user.service.ts
│ │ │ ├── user.repository.ts
│ │ │ ├── user.routes.ts
│ │ │ ├── user.schema.ts # Validações Zod
│ │ │ └── user.types.ts
│ │ ├── auth/
│ │ │ ├── auth.controller.ts
│ │ │ ├── auth.service.ts
│ │ │ ├── auth.routes.ts
│ │ │ └── auth.schema.ts
│ │ └── products/
│ │ ├── product.controller.ts
│ │ ├── product.service.ts
│ │ ├── product.repository.ts
│ │ ├── product.routes.ts
│ │ └── product.schema.ts
│ ├── middlewares/
│ │ ├── auth.middleware.ts # Verificação de JWT
│ │ ├── error.middleware.ts # Tratamento global de erros
│ │ ├── validate.middleware.ts # Validação de schemas
│ │ ├── rate-limit.middleware.ts
│ │ └── logger.middleware.ts
│ ├── shared/
│ │ ├── errors/
│ │ │ ├── AppError.ts
│ │ │ ├── NotFoundError.ts
│ │ │ └── ValidationError.ts
│ │ ├── utils/
│ │ │ ├── pagination.ts
│ │ │ ├── response.ts
│ │ │ └── crypto.ts
│ │ └── types/
│ │ └── express.d.ts # Extensão de tipos do Express
│ ├── app.ts # Configuração do Express
│ └── server.ts # Entry point
├── prisma/
│ ├── schema.prisma
│ ├── migrations/
│ └── seed.ts
├── tests/
│ ├── unit/
│ ├── integration/
│ └── helpers/
├── docker/
│ ├── Dockerfile
│ └── docker-compose.yml
├── .env
├── .env.example
├── .eslintrc.js
├── .prettierrc
├── jest.config.ts
└── package.json
Essa estrutura segue o padrão modular por feature (também chamado de “feature-based architecture”), onde cada módulo de negócio contém seus próprios arquivos de controller, service, repository, routes e schemas. Isso torna muito mais fácil navegar pelo código, testar módulos isoladamente e eventualmente extraí-los para microsserviços.
Por que separar Controller, Service e Repository?
Essa separação implementa o princípio de Responsabilidade Única (SRP) do SOLID:
- Controller: Responsável apenas por receber a requisição HTTP, validar a entrada e retornar a resposta. Não contém lógica de negócio.
- Service: Contém toda a lógica de negócio. Orquestra chamadas ao repository e outras dependências. É testável de forma isolada.
- Repository: Responsável por toda a interação com o banco de dados. Abstrai os detalhes de implementação do ORM.
4. Configurando Express e Middlewares Essenciais {#express}
Instalação das dependências
# Dependências principais
pnpm add express cors helmet morgan compression dotenv zod jsonwebtoken bcryptjs
# Tipos TypeScript
pnpm add -D @types/express @types/cors @types/morgan @types/compression @types/jsonwebtoken @types/bcryptjs
# Ferramentas adicionais
pnpm add express-rate-limit express-async-errors uuid
pnpm add -D @types/uuid
Configuração de Variáveis de Ambiente
Criar um arquivo robusto de configuração de ambiente previne toda uma categoria de bugs:
// src/config/env.ts
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.string().default('3000').transform(Number),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET deve ter pelo menos 32 caracteres'),
JWT_EXPIRES_IN: z.string().default('7d'),
JWT_REFRESH_SECRET: z.string().min(32),
JWT_REFRESH_EXPIRES_IN: z.string().default('30d'),
REDIS_URL: z.string().url().optional(),
CORS_ORIGIN: z.string().default('*'),
RATE_LIMIT_WINDOW_MS: z.string().default('900000').transform(Number),
RATE_LIMIT_MAX_REQUESTS: z.string().default('100').transform(Number),
BCRYPT_SALT_ROUNDS: z.string().default('12').transform(Number),
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
});
const parseResult = envSchema.safeParse(process.env);
if (!parseResult.success) {
console.error('❌ Variáveis de ambiente inválidas:');
console.error(parseResult.error.format());
process.exit(1);
}
export const env = parseResult.data;
export type Env = typeof env;
Configuração do Express App
// src/app.ts
import 'express-async-errors';
import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import compression from 'compression';
import { env } from '@/config/env';
import { errorMiddleware } from '@/middlewares/error.middleware';
import { rateLimitMiddleware } from '@/middlewares/rate-limit.middleware';
import { setupSwagger } from '@/config/swagger';
// Routes
import { userRoutes } from '@/modules/users/user.routes';
import { authRoutes } from '@/modules/auth/auth.routes';
import { productRoutes } from '@/modules/products/product.routes';
export function createApp(): Application {
const app = express();
// =====================
// Middlewares de Segurança
// =====================
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
app.use(cors({
origin: env.CORS_ORIGIN === '*'
? '*'
: env.CORS_ORIGIN.split(',').map(o => o.trim()),
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
credentials: true,
maxAge: 86400, // 24 horas de cache para preflight
}));
// =====================
// Middlewares de Performance
// =====================
app.use(compression({
level: 6,
threshold: 10 * 1024, // Comprimir respostas maiores que 10KB
filter: (req, res) => {
if (req.headers['x-no-compression']) return false;
return compression.filter(req, res);
},
}));
// =====================
// Parsing de Body
// =====================
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// =====================
// Logging
// =====================
if (env.NODE_ENV !== 'test') {
app.use(morgan(env.NODE_ENV === 'production' ? 'combined' : 'dev'));
}
// =====================
// Rate Limiting Global
// =====================
app.use(rateLimitMiddleware);
// =====================
// Health Check
// =====================
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: env.NODE_ENV,
});
});
// =====================
// Documentação (apenas não-produção)
// =====================
if (env.NODE_ENV !== 'production') {
setupSwagger(app);
}
// =====================
// Rotas da API
// =====================
const API_PREFIX = '/api/v1';
app.use(`${API_PREFIX}/auth`, authRoutes);
app.use(`${API_PREFIX}/users`, userRoutes);
app.use(`${API_PREFIX}/products`, productRoutes);
// =====================
// 404 Handler
// =====================
app.use('*', (req, res) => {
res.status(404).json({
status: 'error',
message: `Rota ${req.method} ${req.originalUrl} não encontrada`,
});
});
// =====================
// Error Handler Global (deve ser o último middleware)
// =====================
app.use(errorMiddleware);
return app;
}
Entry Point (server.ts)
// src/server.ts
import { createApp } from './app';
import { env } from './config/env';
import { prisma } from './config/database';
async function bootstrap() {
const app = createApp();
// Verificar conexão com banco de dados
try {
await prisma.$connect();
console.log('✅ Conectado ao banco de dados');
} catch (error) {
console.error('❌ Falha ao conectar ao banco de dados:', error);
process.exit(1);
}
const server = app.listen(env.PORT, () => {
console.log(`🚀 Servidor rodando na porta ${env.PORT}`);
console.log(`📌 Ambiente: ${env.NODE_ENV}`);
console.log(`📚 Documentação: http://localhost:${env.PORT}/api-docs`);
});
// Graceful Shutdown
const gracefulShutdown = async (signal: string) => {
console.log(`n📴 Recebido ${signal}. Iniciando graceful shutdown...`);
server.close(async () => {
console.log('🔌 Servidor HTTP fechado');
await prisma.$disconnect();
console.log('🔌 Banco de dados desconectado');
process.exit(0);
});
// Força encerramento após 30 segundos
setTimeout(() => {
console.error('⏰ Timeout: forçando encerramento');
process.exit(1);
}, 30000);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Tratamento de promessas não capturadas
process.on('unhandledRejection', (reason, promise) => {
console.error('❌ Unhandled Rejection:', reason);
// Em produção, você pode querer reiniciar o processo
});
process.on('uncaughtException', (error) => {
console.error('❌ Uncaught Exception:', error);
process.exit(1);
});
}
bootstrap();
5. Modelagem de Dados com PostgreSQL e Prisma ORM {#banco-de-dados}
Por que Prisma?
Prisma é o ORM mais moderno e type-safe para Node.js/TypeScript. Ao contrário de ORMs mais antigos como Sequelize ou TypeORM, o Prisma gera tipos TypeScript automaticamente a partir do schema, garantindo que todas as queries sejam completamente tipadas.
Instalação do Prisma
pnpm add @prisma/client
pnpm add -D prisma
# Inicializar Prisma com PostgreSQL
npx prisma init --datasource-provider postgresql
Schema do Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch", "filteredRelationCount"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Enum para papéis de usuário
enum Role {
ADMIN
MODERATOR
USER
}
// Enum para status
enum Status {
ACTIVE
INACTIVE
SUSPENDED
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique @db.VarChar(255)
username String @unique @db.VarChar(50)
password String
name String @db.VarChar(100)
role Role @default(USER)
status Status @default(ACTIVE)
avatarUrl String? @map("avatar_url")
bio String? @db.Text
emailVerified Boolean @default(false) @map("email_verified")
emailVerifiedAt DateTime? @map("email_verified_at")
lastLoginAt DateTime? @map("last_login_at")
refreshToken String? @map("refresh_token")
passwordResetToken String? @map("password_reset_token")
passwordResetExpires DateTime? @map("password_reset_expires")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") // Soft delete
// Relações
products Product[]
orders Order[]
refreshTokens RefreshToken[]
@@map("users")
@@index([email])
@@index([username])
@@index([status])
}
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
token String @unique @db.Text
userId String @map("user_id") @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
isRevoked Boolean @default(false) @map("is_revoked")
ipAddress String? @map("ip_address") @db.VarChar(45)
userAgent String? @map("user_agent") @db.Text
@@map("refresh_tokens")
@@index([userId])
@@index([token])
}
model Category {
id String @id @default(uuid()) @db.Uuid
name String @unique @db.VarChar(100)
slug String @unique @db.VarChar(120)
description String? @db.Text
parentId String? @map("parent_id") @db.Uuid
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children Category[] @relation("CategoryHierarchy")
products Product[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("categories")
}
model Product {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(200)
slug String @unique @db.VarChar(220)
description String? @db.Text
price Decimal @db.Decimal(10, 2)
stock Int @default(0)
sku String @unique @db.VarChar(50)
images String[] @db.Text
isActive Boolean @default(true) @map("is_active")
userId String @map("user_id") @db.Uuid
user User @relation(fields: [userId], references: [id])
categoryId String @map("category_id") @db.Uuid
category Category @relation(fields: [categoryId], references: [id])
orderItems OrderItem[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@map("products")
@@index([userId])
@@index([categoryId])
@@index([isActive])
@@index([slug])
}
model Order {
id String @id @default(uuid()) @db.Uuid
orderNumber String @unique @map("order_number") @db.VarChar(20)
userId String @map("user_id") @db.Uuid
user User @relation(fields: [userId], references: [id])
items OrderItem[]
total Decimal @db.Decimal(10, 2)
status OrderStatus @default(PENDING)
notes String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("orders")
@@index([userId])
@@index([status])
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
REFUNDED
}
model OrderItem {
id String @id @default(uuid()) @db.Uuid
orderId String @map("order_id") @db.Uuid
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String @map("product_id") @db.Uuid
product Product @relation(fields: [productId], references: [id])
quantity Int
price Decimal @db.Decimal(10, 2) // Preço no momento da compra
@@map("order_items")
@@index([orderId])
@@index([productId])
}
Configuração do Prisma Client
// src/config/database.ts
import { PrismaClient } from '@prisma/client';
import { env } from './env';
declare global {
var __prisma: PrismaClient | undefined;
}
function createPrismaClient() {
return new PrismaClient({
log: env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
errorFormat: 'minimal',
});
}
// Em desenvolvimento, reutiliza o cliente entre hot-reloads
export const prisma = global.__prisma ?? createPrismaClient();
if (env.NODE_ENV !== 'production') {
global.__prisma = prisma;
}
// Middleware para soft delete automático
prisma.$use(async (params, next) => {
// Interceptar operações de delete
if (params.model === 'User' || params.model === 'Product') {
if (params.action === 'delete') {
params.action = 'update';
params.args['data'] = { deletedAt: new Date() };
}
if (params.action === 'deleteMany') {
params.action = 'updateMany';
params.args['data'] = { deletedAt: new Date() };
}
// Filtrar registros deletados automaticamente
if (params.action === 'findUnique' || params.action === 'findFirst') {
params.action = 'findFirst';
params.args['where']['deletedAt'] = null;
}
if (params.action === 'findMany') {
params.args['where']['deletedAt'] = null;
}
}
return next(params);
});
6. Implementando Autenticação e Autorização com JWT {#autenticacao}
A autenticação é um dos aspectos mais críticos de qualquer API. Vamos implementar um sistema completo com JWT access tokens de curta duração e refresh tokens de longa duração.
Instalação das dependências
pnpm add jsonwebtoken bcryptjs
pnpm add -D @types/jsonwebtoken @types/bcryptjs
Auth Service — Lógica de Negócio
// src/modules/auth/auth.service.ts
import bcrypt from 'bcryptjs';
import jwt, { SignOptions } from 'jsonwebtoken';
import { prisma } from '@/config/database';
import { env } from '@/config/env';
import { AppError } from '@/shared/errors/AppError';
import { User } from '@prisma/client';
interface TokenPayload {
userId: string;
email: string;
role: string;
}
interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface LoginDTO {
email: string;
password: string;
}
interface RegisterDTO {
email: string;
username: string;
password: string;
name: string;
}
export class AuthService {
async register(data: RegisterDTO): Promise<{ user: Omit; tokens: AuthTokens }> {
// Verificar se email já existe
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email: data.email }, { username: data.username }],
},
});
if (existingUser) {
if (existingUser.email === data.email) {
throw new AppError('Email já está em uso', 409);
}
throw new AppError('Username já está em uso', 409);
}
// Hash da senha
const hashedPassword = await bcrypt.hash(data.password, env.BCRYPT_SALT_ROUNDS);
// Criar usuário
const user = await prisma.user.create({
data: {
email: data.email.toLowerCase(),
username: data.username.toLowerCase(),
password: hashedPassword,
name: data.name,
},
});
// Gerar tokens
const tokens = await this.generateTokens(user);
// Remover senha da resposta
const { password, ...userWithoutPassword } = user;
return { user: userWithoutPassword, tokens };
}
async login(data: LoginDTO, ipAddress?: string, userAgent?: string): Promise<{
user: Omit;
tokens: AuthTokens;
}> {
// Buscar usuário
const user = await prisma.user.findUnique({
where: { email: data.email.toLowerCase() },
});
if (!user) {
// Mensagem genérica por segurança (não revela se o email existe)
throw new AppError('Credenciais inválidas', 401);
}
if (user.status === 'SUSPENDED') {
throw new AppError('Conta suspensa. Entre em contato com o suporte.', 403);
}
if (user.status === 'INACTIVE') {
throw new AppError('Conta inativa.', 403);
}
// Verificar senha
const isPasswordValid = await bcrypt.compare(data.password, user.password);
if (!isPasswordValid) {
throw new AppError('Credenciais inválidas', 401);
}
// Atualizar último login
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Gerar tokens
const tokens = await this.generateTokens(user, ipAddress, userAgent);
const { password, ...userWithoutPassword } = user;
return { user: userWithoutPassword, tokens };
}
async refreshTokens(refreshToken: string): Promise {
// Verificar se o refresh token existe e é válido
const storedToken = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: true },
});
if (!storedToken || storedToken.isRevoked) {
throw new AppError('Refresh token inválido ou revogado', 401);
}
if (storedToken.expiresAt < new Date()) {
// Revogar token expirado
await prisma.refreshToken.update({
where: { id: storedToken.id },
data: { isRevoked: true },
});
throw new AppError('Refresh token expirado. Faça login novamente.', 401);
}
// Verificar JWT do refresh token
try {
jwt.verify(refreshToken, env.JWT_REFRESH_SECRET);
} catch {
throw new AppError('Refresh token inválido', 401);
}
// Revogar o token antigo (rotation)
await prisma.refreshToken.update({
where: { id: storedToken.id },
data: { isRevoked: true },
});
// Gerar novos tokens
return this.generateTokens(storedToken.user);
}
async logout(refreshToken: string): Promise {
await prisma.refreshToken.updateMany({
where: { token: refreshToken },
data: { isRevoked: true },
});
}
async logoutAll(userId: string): Promise {
await prisma.refreshToken.updateMany({
where: { userId },
data: { isRevoked: true },
});
}
private async generateTokens(
user: User,
ipAddress?: string,
userAgent?: string
): Promise {
const payload: TokenPayload = {
userId: user.id,
email: user.email,
role: user.role,
};
// Access token (curta duração)
const accessToken = jwt.sign(payload, env.JWT_SECRET, {
expiresIn: env.JWT_EXPIRES_IN,
issuer: 'api-rest-nodejs',
audience: 'api-clients',
} as SignOptions);
// Refresh token (longa duração)
const refreshToken = jwt.sign(
{ userId: user.id },
env.JWT_REFRESH_SECRET,
{ expiresIn: env.JWT_REFRESH_EXPIRES_IN } as SignOptions
);
// Calcular expiração do refresh token
const expiresAt = new Date();
const daysMatch = env.JWT_REFRESH_EXPIRES_IN.match(/(d+)d/);
const days = daysMatch ? parseInt(daysMatch[1]) : 30;
expiresAt.setDate(expiresAt.getDate() + days);
// Salvar refresh token no banco
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt,
ipAddress,
userAgent,
},
});
// Calcular expiresIn em segundos para o cliente
const accessTokenPayload = jwt.decode(accessToken) as { exp: number };
const expiresIn = accessTokenPayload.exp - Math.floor(Date.now() / 1000);
return { accessToken, refreshToken, expiresIn };
}
verifyAccessToken(token: string): TokenPayload {
try {
return jwt.verify(token, env.JWT_SECRET, {
issuer: 'api-rest-nodejs',
audience: 'api-clients',
}) as TokenPayload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AppError('Token expirado', 401);
}
throw new AppError('Token inválido', 401);
}
}
}
export const authService = new AuthService();
Auth Middleware
// src/middlewares/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { authService } from '@/modules/auth/auth.service';
import { AppError } from '@/shared/errors/AppError';
import { Role } from '@prisma/client';
// Extender tipos do Express
declare global {
namespace Express {
interface Request {
user?: {
userId: string;
email: string;
role: Role;
};
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new AppError('Token de autenticação não fornecido', 401);
}
const token = authHeader.substring(7);
const payload = authService.verifyAccessToken(token);
req.user = {
userId: payload.userId,
email: payload.email,
role: payload.role as Role,
};
next();
}
export function authorize(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
throw new AppError('Não autenticado', 401);
}
if (roles.length > 0 && !roles.includes(req.user.role)) {
throw new AppError(
`Acesso negado. Requer uma das roles: ${roles.join(', ')}`,
403
);
}
next();
};
}
7. CRUD Completo: Controllers, Services e Repositories {#crud}
User Repository
// src/modules/users/user.repository.ts
import { prisma } from '@/config/database';
import { User, Prisma } from '@prisma/client';
export interface PaginationOptions {
page: number;
limit: number;
}
export interface UserFilters {
search?: string;
role?: string;
status?: string;
}
export class UserRepository {
async findAll(filters: UserFilters, pagination: PaginationOptions) {
const { page, limit } = pagination;
const skip = (page - 1) * limit;
const where: Prisma.UserWhereInput = {
deletedAt: null,
...(filters.role && { role: filters.role as any }),
...(filters.status && { status: filters.status as any }),
...(filters.search && {
OR: [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ email: { contains: filters.search, mode: 'insensitive' } },
{ username: { contains: filters.search, mode: 'insensitive' } },
],
}),
};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true,
email: true,
username: true,
name: true,
role: true,
status: true,
avatarUrl: true,
createdAt: true,
_count: { select: { products: true, orders: true } },
},
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
return {
users,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findById(id: string) {
return prisma.user.findFirst({
where: { id, deletedAt: null },
select: {
id: true,
email: true,
username: true,
name: true,
role: true,
status: true,
avatarUrl: true,
bio: true,
emailVerified: true,
lastLoginAt: true,
createdAt: true,
updatedAt: true,
_count: { select: { products: true, orders: true } },
},
});
}
async findByEmail(email: string) {
return prisma.user.findFirst({
where: { email: email.toLowerCase(), deletedAt: null },
});
}
async update(id: string, data: Prisma.UserUpdateInput) {
return prisma.user.update({
where: { id },
data,
select: {
id: true,
email: true,
username: true,
name: true,
role: true,
status: true,
avatarUrl: true,
bio: true,
updatedAt: true,
},
});
}
async softDelete(id: string) {
return prisma.user.update({
where: { id },
data: { deletedAt: new Date(), status: 'INACTIVE' },
});
}
}
export const userRepository = new UserRepository();
User Service
// src/modules/users/user.service.ts
import { userRepository, UserFilters, PaginationOptions } from './user.repository';
import { AppError } from '@/shared/errors/AppError';
import { Prisma } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { env } from '@/config/env';
interface UpdateUserDTO {
name?: string;
bio?: string;
avatarUrl?: string;
}
interface UpdatePasswordDTO {
currentPassword: string;
newPassword: string;
}
export class UserService {
async getUsers(filters: UserFilters, pagination: PaginationOptions) {
return userRepository.findAll(filters, pagination);
}
async getUserById(id: string) {
const user = await userRepository.findById(id);
if (!user) {
throw new AppError('Usuário não encontrado', 404);
}
return user;
}
async updateUser(id: string, data: UpdateUserDTO, requesterId: string) {
// Verificar se o usuário existe
const user = await userRepository.findById(id);
if (!user) {
throw new AppError('Usuário não encontrado', 404);
}
// Apenas o próprio usuário ou admin pode atualizar
if (id !== requesterId) {
throw new AppError('Sem permissão para atualizar este usuário', 403);
}
return userRepository.update(id, data as Prisma.UserUpdateInput);
}
async updatePassword(
userId: string,
data: UpdatePasswordDTO,
requesterId: string
) {
if (userId !== requesterId) {
throw new AppError('Sem permissão para alterar esta senha', 403);
}
const user = await userRepository.findById(userId);
if (!user) {
throw new AppError('Usuário não encontrado', 404);
}
// Buscar com senha para verificar
const userWithPassword = await userRepository.findByEmail(user.email);
if (!userWithPassword) throw new AppError('Usuário não encontrado', 404);
const isCurrentPasswordValid = await bcrypt.compare(
data.currentPassword,
userWithPassword.password
);
if (!isCurrentPasswordValid) {
throw new AppError('Senha atual incorreta', 400);
}
const hashedNewPassword = await bcrypt.hash(data.newPassword, env.BCRYPT_SALT_ROUNDS);
await userRepository.update(userId, { password: hashedNewPassword });
return { message: 'Senha atualizada com sucesso' };
}
async deleteUser(id: string, requesterId: string) {
const user = await userRepository.findById(id);
if (!user) {
throw new AppError('Usuário não encontrado', 404);
}
if (id !== requesterId) {
throw new AppError('Sem permissão para deletar este usuário', 403);
}
await userRepository.softDelete(id);
return { message: 'Conta deletada com sucesso' };
}
}
export const userService = new UserService();
User Controller
// src/modules/users/user.controller.ts
import { Request, Response } from 'express';
import { userService } from './user.service';
import { updateUserSchema, updatePasswordSchema } from './user.schema';
export class UserController {
async index(req: Request, res: Response): Promise {
const { page = '1', limit = '10', search, role, status } = req.query;
const result = await userService.getUsers(
{ search: search as string, role: role as string, status: status as string },
{ page: Number(page), limit: Math.min(Number(limit), 100) }
);
return res.status(200).json({
status: 'success',
data: result.users,
meta: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
},
});
}
async show(req: Request, res: Response): Promise {
const { id } = req.params;
const user = await userService.getUserById(id);
return res.status(200).json({
status: 'success',
data: user,
});
}
async update(req: Request, res: Response): Promise {
const { id } = req.params;
const data = updateUserSchema.parse(req.body);
const user = await userService.updateUser(id, data, req.user!.userId);
return res.status(200).json({
status: 'success',
data: user,
message: 'Usuário atualizado com sucesso',
});
}
async updatePassword(req: Request, res: Response): Promise {
const { id } = req.params;
const data = updatePasswordSchema.parse(req.body);
const result = await userService.updatePassword(id, data, req.user!.userId);
return res.status(200).json({
status: 'success',
...result,
});
}
async destroy(req: Request, res: Response): Promise {
const { id } = req.params;
const result = await userService.deleteUser(id, req.user!.userId);
return res.status(200).json({
status: 'success',
...result,
});
}
}
export const userController = new UserController();
User Routes
// src/modules/users/user.routes.ts
import { Router } from 'express';
import { userController } from './user.controller';
import { authenticate, authorize } from '@/middlewares/auth.middleware';
const router = Router();
// Todos os endpoints de usuário requerem autenticação
router.use(authenticate);
// GET /api/v1/users - Listar usuários (apenas ADMIN)
router.get('/', authorize('ADMIN'), (req, res) => userController.index(req, res));
// GET /api/v1/users/:id - Ver usuário
router.get('/:id', (req, res) => userController.show(req, res));
// PUT /api/v1/users/:id - Atualizar usuário
router.put('/:id', (req, res) => userController.update(req, res));
// PATCH /api/v1/users/:id/password - Alterar senha
router.patch('/:id/password', (req, res) => userController.updatePassword(req, res));
// DELETE /api/v1/users/:id - Deletar usuário
router.delete('/:id', (req, res) => userController.destroy(req, res));
export { router as userRoutes };
8. Validação de Dados com Zod {#validacao}
// src/modules/users/user.schema.ts
import { z } from 'zod';
export const updateUserSchema = z.object({
name: z.string()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(100, 'Nome deve ter no máximo 100 caracteres')
.optional(),
bio: z.string()
.max(500, 'Bio deve ter no máximo 500 caracteres')
.nullable()
.optional(),
avatarUrl: z.string()
.url('URL do avatar inválida')
.nullable()
.optional(),
}).strict(); // Rejeitar campos desconhecidos
export const updatePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Senha atual é obrigatória'),
newPassword: z.string()
.min(8, 'Nova senha deve ter pelo menos 8 caracteres')
.max(72, 'Senha muito longa')
.regex(/[A-Z]/, 'Deve conter pelo menos uma letra maiúscula')
.regex(/[a-z]/, 'Deve conter pelo menos uma letra minúscula')
.regex(/[0-9]/, 'Deve conter pelo menos um número')
.regex(/[^A-Za-z0-9]/, 'Deve conter pelo menos um caractere especial'),
confirmPassword: z.string(),
}).refine(
(data) => data.newPassword === data.confirmPassword,
{
message: 'As senhas não coincidem',
path: ['confirmPassword'],
}
);
// src/modules/auth/auth.schema.ts
export const registerSchema = z.object({
email: z.string()
.email('Email inválido')
.toLowerCase()
.max(255, 'Email muito longo'),
username: z.string()
.min(3, 'Username deve ter pelo menos 3 caracteres')
.max(50, 'Username muito longo')
.regex(/^[a-z0-9_-]+$/, 'Username deve conter apenas letras minúsculas, números, _ e -')
.toLowerCase(),
password: z.string()
.min(8, 'Senha deve ter pelo menos 8 caracteres')
.max(72, 'Senha muito longa')
.regex(/[A-Z]/, 'Deve conter pelo menos uma letra maiúscula')
.regex(/[a-z]/, 'Deve conter pelo menos uma letra minúscula')
.regex(/[0-9]/, 'Deve conter pelo menos um número'),
name: z.string()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(100, 'Nome muito longo')
.trim(),
});
export const loginSchema = z.object({
email: z.string().email('Email inválido').toLowerCase(),
password: z.string().min(1, 'Senha é obrigatória'),
});
Middleware de Validação
// src/middlewares/validate.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
type ValidateTarget = 'body' | 'params' | 'query';
export function validate(schema: ZodSchema, target: ValidateTarget = 'body') {
return (req: Request, res: Response, next: NextFunction): void => {
try {
const data = schema.parse(req[target]);
req[target] = data; // Substitui com dados validados/transformados
next();
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({
status: 'error',
message: 'Dados de entrada inválidos',
errors: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
})),
});
return;
}
next(error);
}
};
}
9. Tratamento de Erros Profissional {#erros}
// src/shared/errors/AppError.ts
export class AppError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean;
constructor(
message: string,
statusCode: number = 500,
isOperational: boolean = true
) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Object.setPrototypeOf(this, new.target.prototype);
Error.captureStackTrace(this, this.constructor);
}
}
// src/middlewares/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { Prisma } from '@prisma/client';
import { ZodError } from 'zod';
import { AppError } from '@/shared/errors/AppError';
import { env } from '@/config/env';
export function errorMiddleware(
error: Error,
req: Request,
res: Response,
next: NextFunction
): Response {
// Log do erro (em produção usar Winston/Pino)
console.error({
timestamp: new Date().toISOString(),
path: req.path,
method: req.method,
error: error.message,
stack: env.NODE_ENV !== 'production' ? error.stack : undefined,
});
// Erro operacional (esperado)
if (error instanceof AppError) {
return res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
}
// Erros do Prisma
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
const field = (error.meta?.target as string[])?.join(', ');
return res.status(409).json({
status: 'error',
message: `Violação de unicidade no campo: ${field}`,
});
}
if (error.code === 'P2025') {
return res.status(404).json({
status: 'error',
message: 'Registro não encontrado',
});
}
}
// Erros de validação Zod
if (error instanceof ZodError) {
return res.status(400).json({
status: 'error',
message: 'Dados inválidos',
errors: error.errors,
});
}
// Erro desconhecido/não operacional
return res.status(500).json({
status: 'error',
message: env.NODE_ENV === 'production'
? 'Erro interno do servidor'
: error.message,
...(env.NODE_ENV !== 'production' && { stack: error.stack }),
});
}
10. Segurança: Rate Limiting, CORS, Helmet e mais {#seguranca}
// src/middlewares/rate-limit.middleware.ts
import rateLimit from 'express-rate-limit';
import { env } from '@/config/env';
export const rateLimitMiddleware = rateLimit({
windowMs: env.RATE_LIMIT_WINDOW_MS, // 15 minutos por padrão
max: env.RATE_LIMIT_MAX_REQUESTS, // 100 req por janela
message: {
status: 'error',
message: 'Muitas requisições. Tente novamente em alguns minutos.',
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// Não limitar o endpoint de health check
return req.path === '/health';
},
keyGenerator: (req) => {
// Usar IP real (considerando proxies)
return req.ip || req.headers['x-forwarded-for'] as string || 'unknown';
},
});
// Rate limit mais restrito para auth
export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 10, // Apenas 10 tentativas de login por 15 minutos
message: {
status: 'error',
message: 'Muitas tentativas de login. Aguarde 15 minutos.',
},
standardHeaders: true,
legacyHeaders: false,
});
Checklist de Segurança Completa
Uma API REST segura precisa considerar múltiplos vetores de ataque. Aqui está um checklist abrangente:
Autenticação e Autorização
- Usar HTTPS em produção (TLS 1.2+)
- Tokens JWT com tempo de expiração curto (15min a 1h)
- Refresh tokens com rotação (invalidar após uso)
- Senhas hasheadas com bcrypt (salt rounds ≥ 12)
- Rate limiting em endpoints de autenticação
- Bloqueio temporário após N tentativas falhas
Proteção de Dados
- Validação rigorosa de entrada (Zod/Joi)
- Sanitização de dados antes de inserir no banco
- Nunca retornar senhas ou dados sensíveis nas respostas
- Usar soft delete para preservar integridade dos dados
- Criptografar dados sensíveis em repouso
Headers HTTP de Segurança (via Helmet)
X-Content-Type-Options: nosniffX-Frame-Options: DENYX-XSS-Protection: 1; mode=blockStrict-Transport-SecurityContent-Security-PolicyReferrer-Policy
Proteção contra Ataques Comuns
- SQL Injection: use ORM parametrizado (Prisma)
- XSS: sanitizar inputs HTML
- CSRF: tokens CSRF para formulários
- NoSQL Injection: validar tipos de dados
- Path Traversal: validar caminhos de arquivo
11. Testes Automatizados: Unitários e de Integração {#testes}
pnpm add -D jest @types/jest ts-jest supertest @types/supertest
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['/tests'],
moduleNameMapper: {
'^@/(.*)$': '/src/$1',
},
setupFilesAfterFramework: ['/tests/helpers/setup.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/server.ts',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
};
export default config;
// tests/integration/auth.test.ts
import request from 'supertest';
import { createApp } from '@/app';
import { prisma } from '@/config/database';
const app = createApp();
describe('Auth Routes', () => {
beforeEach(async () => {
await prisma.user.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
});
describe('POST /api/v1/auth/register', () => {
it('deve registrar um novo usuário com sucesso', async () => {
const response = await request(app)
.post('/api/v1/auth/register')
.send({
email: 'test@example.com',
username: 'testuser',
password: 'SecurePass1!',
name: 'Test User',
});
expect(response.status).toBe(201);
expect(response.body.status).toBe('success');
expect(response.body.data.user.email).toBe('test@example.com');
expect(response.body.data.tokens.accessToken).toBeDefined();
expect(response.body.data.user.password).toBeUndefined();
});
it('deve rejeitar email duplicado', async () => {
await request(app).post('/api/v1/auth/register').send({
email: 'duplicate@example.com',
username: 'user1',
password: 'SecurePass1!',
name: 'User 1',
});
const response = await request(app)
.post('/api/v1/auth/register')
.send({
email: 'duplicate@example.com',
username: 'user2',
password: 'SecurePass1!',
name: 'User 2',
});
expect(response.status).toBe(409);
});
it('deve rejeitar senha fraca', async () => {
const response = await request(app)
.post('/api/v1/auth/register')
.send({
email: 'test@example.com',
username: 'testuser',
password: '123',
name: 'Test User',
});
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
});
describe('POST /api/v1/auth/login', () => {
beforeEach(async () => {
await request(app).post('/api/v1/auth/register').send({
email: 'login@example.com',
username: 'loginuser',
password: 'SecurePass1!',
name: 'Login User',
});
});
it('deve fazer login com credenciais corretas', async () => {
const response = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'login@example.com', password: 'SecurePass1!' });
expect(response.status).toBe(200);
expect(response.body.data.tokens.accessToken).toBeDefined();
expect(response.body.data.tokens.refreshToken).toBeDefined();
});
it('deve rejeitar credenciais incorretas', async () => {
const response = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'login@example.com', password: 'WrongPassword1!' });
expect(response.status).toBe(401);
expect(response.body.message).toBe('Credenciais inválidas');
});
});
});
12. Documentação com Swagger/OpenAPI {#documentacao}
pnpm add swagger-ui-express swagger-jsdoc
pnpm add -D @types/swagger-ui-express @types/swagger-jsdoc
// src/config/swagger.ts
import { Application } from 'express';
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
import { env } from './env';
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'API REST Node.js 2025',
version: '1.0.0',
description: 'API RESTful completa construída com Node.js, TypeScript, Express e Prisma',
contact: {
name: 'Suporte',
email: 'api@example.com',
},
license: { name: 'MIT' },
},
servers: [
{ url: `http://localhost:${env.PORT}/api/v1`, description: 'Desenvolvimento' },
{ url: 'https://api.example.com/v1', description: 'Produção' },
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
schemas: {
Error: {
type: 'object',
properties: {
status: { type: 'string', example: 'error' },
message: { type: 'string' },
},
},
User: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
username: { type: 'string' },
name: { type: 'string' },
role: { type: 'string', enum: ['ADMIN', 'MODERATOR', 'USER'] },
createdAt: { type: 'string', format: 'date-time' },
},
},
},
},
};
export function setupSwagger(app: Application): void {
const options = {
swaggerDefinition,
apis: ['./src/modules/**/*.routes.ts'],
};
const swaggerSpec = swaggerJsdoc(options);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'API Docs',
}));
}
13. Logging e Monitoramento {#logging}
pnpm add winston winston-daily-rotate-file
// src/config/logger.ts
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import { env } from './env';
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
);
export const logger = winston.createLogger({
level: env.LOG_LEVEL,
format: logFormat,
defaultMeta: { service: 'api-rest' },
transports: [
// Console (desenvolvimento)
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
silent: env.NODE_ENV === 'test',
}),
// Arquivo de erros
new DailyRotateFile({
filename: 'logs/error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
level: 'error',
maxFiles: '14d',
maxSize: '20m',
}),
// Arquivo geral
new DailyRotateFile({
filename: 'logs/combined-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxFiles: '7d',
maxSize: '20m',
}),
],
});
14. Deploy em Produção: Docker e Cloud {#deploy}
Dockerfile Otimizado
# Dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Instalar pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copiar arquivos de dependências
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma/
# Instalar dependências (incluindo devDependencies para build)
RUN pnpm install --frozen-lockfile
# Copiar código fonte
COPY . .
# Gerar Prisma Client
RUN pnpm db:generate
# Build TypeScript
RUN pnpm build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
# Criar usuário não-root para segurança
RUN addgroup -g 1001 -S nodejs && adduser -S api -u 1001
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma/
# Instalar apenas dependências de produção
RUN pnpm install --frozen-lockfile --prod
# Gerar Prisma Client em produção
RUN pnpm db:generate
# Copiar build
COPY --from=builder /app/dist ./dist
# Mudar para usuário não-root
USER api
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
EXPOSE 3000
CMD ["node", "dist/server.js"]
Docker Compose para Desenvolvimento
# docker-compose.yml
version: '3.9'
services:
api:
build:
context: .
target: production
ports:
- '3000:3000'
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://postgres:postgres@db:5432/apidb
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
networks:
- app-network
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: apidb
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 5
networks:
- app-network
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/ssl:/etc/nginx/ssl
depends_on:
- api
networks:
- app-network
volumes:
postgres_data:
redis_data:
networks:
app-network:
driver: bridge
15. Boas Práticas e Padrões de Projeto {#boas-praticas}
Convenções de Nomenclatura de Endpoints
Uma API REST bem projetada segue convenções consistentes:
# Recursos no plural, substantivos (não verbos)
GET /api/v1/users # Listar usuários
POST /api/v1/users # Criar usuário
GET /api/v1/users/:id # Ver usuário específico
PUT /api/v1/users/:id # Atualizar completo
PATCH /api/v1/users/:id # Atualizar parcial
DELETE /api/v1/users/:id # Deletar
# Relações aninhadas
GET /api/v1/users/:id/orders # Pedidos de um usuário
GET /api/v1/users/:id/orders/:orderId
# Ações que não se encaixam no CRUD
POST /api/v1/auth/login
POST /api/v1/auth/logout
POST /api/v1/users/:id/avatar # Upload de avatar
POST /api/v1/orders/:id/cancel # Cancelar pedido
Status Codes HTTP Corretos
| Situação | Código | Descrição |
|———-|——–|———–|
| Sucesso GET/PUT/PATCH | 200 | OK |
| Criação bem-sucedida | 201 | Created |
| Sem conteúdo | 204 | No Content |
| Dados inválidos | 400 | Bad Request |
| Não autenticado | 401 | Unauthorized |
| Sem permissão | 403 | Forbidden |
| Não encontrado | 404 | Not Found |
| Conflito | 409 | Conflict |
| Rate limit | 429 | Too Many Requests |
| Erro do servidor | 500 | Internal Server Error |
Paginação e Filtros
// src/shared/utils/pagination.ts
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
nextPage: number | null;
previousPage: number | null;
}
export function buildPaginationMeta(
total: number,
page: number,
limit: number
): PaginationMeta {
const totalPages = Math.ceil(total / limit);
return {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
nextPage: page < totalPages ? page + 1 : null,
previousPage: page > 1 ? page - 1 : null,
};
}
Versionamento de API
O versionamento é crucial para não quebrar clientes existentes ao evoluir a API:
// Estratégias de versionamento
// 1. Via URL (mais comum e recomendada)
// /api/v1/users
// /api/v2/users
// 2. Via Header
// Accept: application/vnd.api+json;version=1
// 3. Via Query Parameter (menos recomendada)
// /api/users?version=1
// Implementando múltiplas versões no Express
const v1Router = Router();
const v2Router = Router();
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
16. Conclusão {#conclusao}
Parabéns! Você percorreu um caminho extenso e aprendeu a construir uma API REST Node.js completa e profissional. Vamos recapitular os principais conceitos abordados:
O que construímos:
- API REST estruturada com arquitetura modular e escalável
- Autenticação completa com JWT + Refresh Tokens com rotação
- Banco de dados PostgreSQL com Prisma ORM e soft delete
- Sistema de validação robusto com Zod
- Tratamento profissional de erros com classes especializadas
- Segurança multicamadas (Helmet, CORS, Rate Limiting, bcrypt)
- Testes automatizados de integração com Supertest
- Documentação automática com Swagger/OpenAPI
- Logging estruturado com Winston
- Deploy containerizado com Docker
Próximos passos para aprofundar:
- Implementar cache com Redis para endpoints de leitura frequente
- Adicionar WebSockets para recursos em tempo real
- Explorar GraphQL como alternativa ao REST para casos de uso específicos
- Implementar CQRS (Command Query Responsibility Segregation) para sistemas de alta escala
- Configurar CI/CD com GitHub Actions
- Implementar observabilidade com Prometheus + Grafana
- Explorar microsserviços com comunicação via mensageria (RabbitMQ, Kafka)
O ecossistema Node.js evolui rapidamente. Mantenha-se atualizado com as releases do Node.js LTS, acompanhe os changelogs do Prisma e Express, e participe da comunidade via GitHub e fóruns especializados.
Publicado em 2025 | Categoria: Backend Development | Tags: Node.js, API REST, TypeScript, Express, Prisma, PostgreSQL, JWT, Docker