Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
TutorialIntermediáriocódigo testado

API REST com MongoDB: validação, paginação e testes

Monte uma API completa com Node.js, TypeScript, Express 5 e MongoDB, com CRUD HTTP, schema, índice único, busca, paginação, aggregation e testes.

Rodolfo Mori12 min de leitura

Neste tutorial você vai construir uma API REST de produtos que grava dados no MongoDB, valida entradas, executa CRUD, busca paginada e aggregation, e responde com erros HTTP previsíveis. No fim, typecheck, oito testes de integração e um smoke test comprovam o projeto rodando de verdade.

A base completa está em blog/examples/api-mongodb/. Ela usa Node 24 LTS, TypeScript, Express 5 e o driver oficial do MongoDB, sem esconder o acesso ao banco atrás de um ORM. Você precisa de Docker com Compose e de um terminal; a execução principal acontece em contêiner, então a versão global do seu Node não decide o resultado.

Se documentos, coleções e BSON ainda forem novidade, leia primeiro documentos e coleções no MongoDB. Aqui a gente parte dessa base para montar uma aplicação HTTP completa.

Modelo mental: balcão, conferência e estoque

Imagine uma loja com um balcão de atendimento e um estoque. O cliente faz um pedido no balcão; o atendente confere se o formulário está preenchido; só então o estoquista procura ou altera a ficha do produto. Se algo falha, o cliente recebe uma resposta que explica o tipo de problema sem abrir a sala do estoque.

Na API, a rota HTTP é o balcão, a validação TypeScript em tempo de execução é a conferência e a coleção products é o estoque. O repositório concentra as operações do driver. O middleware de erro traduz falhas para 400, 404, 409 ou 500. O índice único funciona como uma regra que proíbe duas fichas com o mesmo código de catálogo.

O limite da analogia importa: o MongoDB não atende pedidos um por um como uma pessoa. Ele pode usar índices, processar consultas concorrentes e executar um pipeline sobre muitos documentos. Voltando ao termo técnico, nosso fluxo é request → validation → repository → MongoDB → response, com contratos nas duas extremidades da persistência.

text
cliente HTTP

Express: rota + validação + status

ProductStore: contrato da aplicação

MongoProductStore: driver mongodb 7.5.0

coleção products: JSON Schema + índices

Essa separação também deixa o erro 500 testável: no teste, trocamos somente o ProductStore por uma versão que falha. Não precisamos criar uma rota secreta que quebra a aplicação real.

Prepare o projeto com versões reproduzíveis

Entre na pasta do exemplo e confirme as imagens fixadas. Usamos uma versão exata do Node 24, não a etiqueta móvel latest, e uma versão exata do MongoDB 8.0. Isso reduz a frase “na minha máquina funciona” a uma diferença que a gente consegue investigar.

bash
cd blog/examples/api-mongodb
docker --version
docker compose version
docker run --rm node:24.19.0-bookworm-slim node --version
docker run --rm mongo:8.0.28 mongod --version | head -n 1
Docker version 29.5.3, build 03e7e39 Docker Compose version v5.1.4 v24.19.0 db version v8.0.28

O package.json fixa também as bibliotecas. tsx executa TypeScript no modo de desenvolvimento e nos testes; tsc verifica tipos e gera JavaScript para a imagem de execução.

json
{
  "name": "api-mongodb-devclub",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": "24.x" },
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/server.js",
    "check": "tsc -p tsconfig.json --noEmit",
    "test": "node --import tsx --test --test-reporter spec tests/**/*.test.ts",
    "test:smoke": "node scripts/smoke.mjs",
    "money:demo": "node scripts/money-demo.mjs"
  },
  "dependencies": { "express": "5.2.1", "mongodb": "7.5.0" },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/node": "24.13.3",
    "@types/supertest": "7.2.1",
    "supertest": "7.2.2",
    "tsx": "4.23.12",
    "typescript": "7.0.2"
  }
}

O lockfile já faz parte do exemplo. Por isso, a instalação reproduzível usa npm ci: ela instala exatamente a árvore registrada e falha se o manifesto e o lockfile divergirem.

bash
npm ci
npm run check
added 120 packages, and audited 121 packages in 1s found 0 vulnerabilities

> api-mongodb-devclub@1.0.0 check > tsc -p tsconfig.json –noEmit

O TypeScript usa ESM com NodeNext, regras estritas e JavaScript compatível com o Node 24. noUncheckedIndexedAccess obriga a considerar acessos que podem não existir; exactOptionalPropertyTypes diferencia um campo ausente de um campo presente com undefined.

json
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": ".",
    "outDir": "dist",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "tests/**/*.ts"]
}

Suba um MongoDB local sem confundir laboratório com produção

O arquivo .env.example documenta a porta, o host, a URI e o database. Não há senha porque este ambiente é deliberadamente local. O .gitignore exclui .env, então uma configuração particular não entra no Git por acidente.

bash
PORT=3000
HOST=0.0.0.0
MONGODB_URI=mongodb://127.0.0.1:27019
MONGODB_DATABASE=devclub_catalog

No Compose, o nome mongo vira o endereço interno usado pela API. A porta do banco é publicada apenas em 127.0.0.1; uma máquina externa não deve alcançar esse laboratório pela interface de rede. O healthcheck impede a API de partir antes de o banco responder a ping.

yaml
name: devclub-api-mongodb

services:
  mongo:
    image: mongo:8.0.28
    ports: ['127.0.0.1:${MONGO_PORT:-27019}:27017']
    volumes: [mongo-data:/data/db]
    healthcheck:
      test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand({ ping: 1 }).ok']
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s

  api:
    build: { context: ., target: runtime }
    environment:
      HOST: 0.0.0.0
      PORT: 3000
      MONGODB_URI: mongodb://mongo:27017
      MONGODB_DATABASE: devclub_catalog
    ports: ['127.0.0.1:${API_PORT:-3000}:3000']
    depends_on:
      mongo: { condition: service_healthy }

volumes:
  mongo-data:

Suba somente o banco durante o desenvolvimento. docker compose ps precisa mostrar healthy antes de rodarmos os testes.

bash
docker compose up -d mongo
docker compose ps
NAME IMAGE SERVICE STATUS devclub-api-mongodb-mongo-1 mongo:8.0.28 mongo Up (healthy)

Modele o documento e valide em duas barreiras

O tipo ProductDocument descreve o documento dentro do código. A entrada de criação não aceita datas nem _id; esses valores pertencem ao servidor. A resposta troca ObjectId e Date por strings, que atravessam JSON sem uma convenção escondida.

ts
export type ProductDocument = {
  _id?: ObjectId;
  name: string;
  slug: string;
  description: string;
  priceCents: number;
  stock: number;
  categories: string[];
  active: boolean;
  createdAt: Date;
  updatedAt: Date;
};

export type CreateProductInput = Pick<ProductDocument,
  'name' | 'slug' | 'description' | 'priceCents' | 'stock' | 'categories' | 'active'
>;
export type UpdateProductInput = Partial<CreateProductInput>;

TypeScript não valida o JSON recebido: os tipos desaparecem quando o programa vira JavaScript. Por isso parseCreateProduct() recebe unknown, rejeita campos desconhecidos, procura os obrigatórios e valida cada valor em runtime.

ts
export function parseCreateProduct(value: unknown): CreateProductInput {
  const body = objectBody(value);
  rejectUnknownFields(body);
  const required = [...CREATE_FIELDS];
  const missing = required.filter((field) => !(field in body));
  if (missing.length > 0) {
    throw new HttpError(400, 'Faltam campos obrigatórios.', missing);
  }
  return Object.fromEntries(
    required.map((field) => [field, parseField(body, field)]),
  ) as CreateProductInput;
}

Preço e estoque são inteiros seguros com limites de negócio. A mesma função impede NaN, frações e números acima da faixa combinada.

ts
function integerField(body: Record<string, unknown>, name: string,
  minimum: number, maximum: number): number {
  const value = body[name];
  if (!Number.isSafeInteger(value) ||
      (value as number) < minimum || (value as number) > maximum) {
    throw new HttpError(400,
      `${name} precisa ser um inteiro entre ${minimum} e ${maximum}.`);
  }
  return value as number;
}

Pense nas duas validações como a conferência do balcão e a trava do estoque. A primeira produz uma mensagem adequada ao cliente. A segunda protege a coleção se um script, uma migração ou outro serviço contornar a API. No MongoDB, o nome técnico dessa trava é schema validation com $jsonSchema.

ts
const PRODUCT_VALIDATOR = {
  $jsonSchema: {
    bsonType: 'object',
    required: [
      '_id', 'name', 'slug', 'description', 'priceCents', 'stock',
      'categories', 'active', 'createdAt', 'updatedAt',
    ],
    additionalProperties: false,
    properties: {
      _id: { bsonType: 'objectId' },
      name: { bsonType: 'string', minLength: 2, maxLength: 120 },
      slug: { bsonType: 'string', pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' },
      description: { bsonType: 'string', minLength: 10, maxLength: 500 },
      priceCents: { bsonType: ['int', 'long'], minimum: 0, maximum: 2_000_000_000 },
      stock: { bsonType: ['int', 'long'], minimum: 0, maximum: 1_000_000 },
      categories: { bsonType: 'array', minItems: 1, maxItems: 10,
        uniqueItems: true, items: { bsonType: 'string' } },
      active: { bsonType: 'bool' },
      createdAt: { bsonType: 'date' },
      updatedAt: { bsonType: 'date' },
    },
  },
};

Como usamos additionalProperties: false, _id precisa aparecer em properties; o próprio MongoDB adiciona esse campo. initializeCatalog() cria a coleção na primeira execução ou atualiza a regra com collMod nas próximas.

ts
if (await db.listCollections({ name: 'products' }, { nameOnly: true }).hasNext()) {
  await db.command({ collMod: 'products', validator: PRODUCT_VALIDATOR,
    validationLevel: 'strict', validationAction: 'error' });
} else {
  await db.createCollection('products', { validator: PRODUCT_VALIDATOR,
    validationLevel: 'strict', validationAction: 'error' });
}

Depois, criamos um índice único para o slug. Ele não acelera apenas a consulta: unique: true impõe uma regra de unicidade. Se duas requisições concorrentes tentarem reservar o mesmo slug, o banco decide qual vence. Fazer apenas um findOne() antes do insert deixaria uma janela de corrida.

ts
const products = db.collection<ProductDocument>('products');
await products.createIndex(
  { slug: 1 },
  { name: 'uq_products_slug', unique: true },
);
await products.createIndex(
  { active: 1, createdAt: -1 },
  { name: 'ix_products_active_created' },
);

Para aprofundar a decisão, compare com a lição de índices no MongoDB. Índice melhora um caminho de leitura e cobra armazenamento e trabalho nas escritas; não é um adesivo para colocar em todo campo.

Implemente CRUD atrás de um repositório

ProductStore é o contrato que a camada HTTP conhece. MongoProductStore é a implementação concreta com o driver. Isso não promete trocar de banco sem custo; apenas evita espalhar collection.find() por todas as rotas.

ts
export interface ProductStore {
  ping(): Promise<void>;
  create(input: CreateProductInput): Promise<ProductResponse>;
  list(options: ListProductsOptions): Promise<ProductList>;
  findById(id: ObjectId): Promise<ProductResponse | null>;
  update(id: ObjectId, input: UpdateProductInput): Promise<ProductResponse | null>;
  delete(id: ObjectId): Promise<boolean>;
  reportByCategory(): Promise<CategoryReport[]>;
}

No create, o servidor define as datas, insertOne() grava o documento e a função serialize() prepara a resposta. O nome técnico serialização quer dizer transformar os valores internos em uma forma que o cliente consegue receber, neste caso JSON.

ts
async create(input: CreateProductInput): Promise<ProductResponse> {
  const now = new Date();
  const document: ProductDocument = { ...input, createdAt: now, updatedAt: now };
  const result = await this.collection.insertOne(document);
  return serialize({ ...document, _id: result.insertedId });
}

Read, update e delete delimitam o alvo pelo _id. findOneAndUpdate() devolve o documento já alterado com returnDocument: 'after'; findOneAndDelete() permite saber se existia algo para remover.

ts
async update(id: ObjectId, input: UpdateProductInput) {
  const document = await this.collection.findOneAndUpdate(
    { _id: id },
    { $set: { ...input, updatedAt: new Date() } },
    { returnDocument: 'after' },
  );
  return document ? serialize(document) : null;
}

async delete(id: ObjectId): Promise<boolean> {
  return await this.collection.findOneAndDelete({ _id: id }) !== null;
}

Esse é o mesmo vocabulário apresentado em CRUD com MongoDB e Node.js, agora aplicado a rotas, validação e códigos HTTP.

Adicione busca e paginação com limites

A rota aceita q, active, page e limit. A validação restringe limit a 50 e page a um inteiro positivo. Limitar não é apenas uma decisão visual: é uma proteção contra respostas gigantes e consumo desnecessário de memória.

A busca escapa os caracteres especiais antes de criar a expressão regular. Sem isso, uma entrada como .* mudaria o significado da consulta. Mesmo escapada, busca por regex sem prefixo pode ficar cara em coleções grandes; para um catálogo real, meça o plano e avalie recursos próprios de busca textual.

ts
const filter: Filter<ProductDocument> = {};
if (options.active !== undefined) filter.active = options.active;
if (options.query) {
  const pattern = escapeRegExp(options.query);
  filter.$or = [
    { name: { $regex: pattern, $options: 'i' } },
    { description: { $regex: pattern, $options: 'i' } },
  ];
}
const skip = (options.page - 1) * options.limit;

Consulta e contagem podem começar juntas com Promise.all(). A ordenação inclui _id como desempate, o que evita ordem ambígua quando dois documentos têm a mesma data. skip/limit é compreensível e suficiente para este laboratório; em páginas muito profundas, paginação por cursor costuma escalar melhor.

ts
const [documents, total] = await Promise.all([
  this.collection.find(filter).sort({ createdAt: -1, _id: -1 })
    .skip(skip).limit(options.limit).toArray(),
  this.collection.countDocuments(filter),
]);
return { items: documents.map(serialize), page: options.page,
  limit: options.limit, total, pages: total ? Math.ceil(total / options.limit) : 0 };

Resuma categorias com um aggregation pipeline

Um aggregation pipeline é uma sequência de estágios: cada estágio recebe documentos, transforma ou reduz o conjunto e entrega o resultado ao próximo. Pense numa linha de separação de pedidos: primeiro ficam apenas os ativos, depois cada categoria de um produto vira uma unidade, e então unidades iguais são agrupadas. O limite é que isso acontece no mecanismo do banco, não numa esteira física e nem necessariamente documento por documento.

ts
return this.collection.aggregate<CategoryReport>([
  { $match: { active: true } },
  { $unwind: '$categories' },
  { $group: {
    _id: '$categories',
    products: { $sum: 1 },
    averagePriceCents: { $avg: '$priceCents' },
    totalStock: { $sum: '$stock' },
  } },
  { $project: { _id: 0, category: '$_id', products: 1,
    averagePriceCents: { $round: ['$averagePriceCents', 0] }, totalStock: 1 } },
  { $sort: { products: -1, category: 1 } },
]).toArray();

O exemplo calcula quantidade de produtos, preço médio em centavos e estoque total por categoria. A lição de aggregation pipeline explica como ler e testar cada estágio isoladamente.

Ligue as rotas e traduza erros para HTTP

As rotas chamam validação e repositório, mas não conhecem detalhes de índice ou pipeline. Em Express 5, uma Promise rejeitada por um handler assíncrono é encaminhada ao middleware de erro. Assim, throw dentro da rota chega ao mesmo lugar sem um try/catch repetido em cada endpoint.

ts
app.post('/products', async (request, response) => {
  const product = await store.create(parseCreateProduct(request.body));
  response.location(`/products/${product.id}`).status(201).json(product);
});

app.get('/products/:id', async (request, response) => {
  const product = await store.findById(parseObjectId(request.params.id));
  if (!product) throw new HttpError(404, 'Produto não encontrado.');
  response.json(product);
});

O mapa de erros tem quatro decisões: entrada inválida vira 400; recurso ausente, 404; colisão do índice, 409; falha não prevista, 500. Cada resposta recebe um x-request-id. O servidor registra o erro inesperado com esse identificador, mas não devolve stack trace, URI do banco ou mensagem interna ao cliente.

ts
if (error instanceof HttpError) {
  return response.status(error.status).json({ error: error.message,
    ...(error.details ? { details: error.details } : {}), requestId });
}
if (error instanceof MongoServerError && error.code === 11000) {
  return response.status(409).json({
    error: 'Já existe um produto com esse slug.', requestId,
  });
}
logger.error(`[${requestId}]`, error);
return response.status(500).json({ error: 'Erro interno do servidor.', requestId });

O servidor abre uma única conexão na inicialização, cria índices e regras e só então começa a escutar. Nos sinais de encerramento, para de aceitar conexões e fecha o MongoClient. Isso é graceful shutdown, ou encerramento coordenado.

ts
const { client, db } = await connectDatabase(config.mongoUri, config.databaseName);
const app = createApp(new MongoProductStore(db.collection('products')));
const server = createServer(app);
server.listen(config.port, config.host);

process.once('SIGTERM', async () => {
  await new Promise<void>((resolve, reject) =>
    server.close((error) => error ? reject(error) : resolve()));
  await client.close();
});

Gere a imagem e execute a API

O Dockerfile separa dependências, compilação e runtime. A imagem final leva somente dependências de produção e o JavaScript compilado; o processo roda como o usuário não privilegiado node.

dockerfile
FROM node:24.19.0-bookworm-slim AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM dependencies AS build
COPY tsconfig.json ./
COPY src ./src
COPY tests ./tests
COPY scripts ./scripts
RUN npm run build

FROM node:24.19.0-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist/src ./dist
COPY scripts ./scripts
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Suba API e banco. Se a porta 3000 já estiver ocupada, use API_PORT=3019 como abaixo; isso muda apenas a porta do computador, não a comunicação interna.

bash
API_PORT=3019 docker compose up -d --build api
curl -sS http://127.0.0.1:3019/health
{"status":"ok"}

Agora crie um produto. O corpo usa centavos inteiros: 15990 representa R$ 159,90. A resposta real abaixo mostra o ObjectId serializado e as datas criadas pelo servidor.

bash
curl -sS -X POST http://127.0.0.1:3019/products \
  -H 'content-type: application/json' \
  -d '{"name":"Suporte para notebook","slug":"suporte-notebook","description":"Suporte de alumínio para organizar a mesa.","priceCents":15990,"stock":12,"categories":["escritorio","acessorios"],"active":true}'
{"id":"6a8a60adc388121a9d996242","name":"Suporte para notebook","slug":"suporte-notebook","description":"Suporte de alumínio para organizar a mesa.","priceCents":15990,"stock":12,"categories":["escritorio","acessorios"],"active":true,"createdAt":"2026-08-23T02:53:33.265Z","updatedAt":"2026-08-23T02:53:33.265Z"}

Reproduza 400, 404 e 409 antes de corrigi-los

Um id curto não tem o formato de ObjectId. A rota recusa a entrada antes de consultar o banco e devolve 400, que significa requisição inválida.

bash
curl -sS -i http://127.0.0.1:3019/products/abc
HTTP/1.1 400 Bad Request {"error":"id precisa ter 24 caracteres hexadecimais.","requestId":"65eb8e04-ad88-419a-8f7a-7b28e23ffea0"}

Um id bem formado pode não existir. Agora a requisição é válida, mas o recurso não foi encontrado; por isso o status correto muda para 404.

bash
curl -sS -i http://127.0.0.1:3019/products/000000000000000000000000
HTTP/1.1 404 Not Found {"error":"Produto não encontrado.","requestId":"b9f57a94-197f-431f-972c-7e1dc413206c"}

Repita o POST com slug: "suporte-notebook". O MongoDB acusa E11000 e código 11000; a API traduz a condição para 409 Conflict. O erro prova que a unicidade está no banco, onde também protege requisições concorrentes.

bash
curl -sS -i -X POST http://127.0.0.1:3019/products \
  -H 'content-type: application/json' \
  -d '{"name":"Suporte repetido","slug":"suporte-notebook","description":"Este produto repete o mesmo slug do anterior.","priceCents":1000,"stock":1,"categories":["acessorios"],"active":true}'
HTTP/1.1 409 Conflict {"error":"Já existe um produto com esse slug.","requestId":"f98960f0-8c88-4d6e-9f86-d20aecc1b25c"}

Consulte, atualize, agregue e remova

Busca e paginação compõem a query string. O retorno inclui a página atual, o limite aplicado, o total e quantas páginas existem.

bash
curl -sS 'http://127.0.0.1:3019/products?q=suporte&active=true&page=1&limit=5'
{"items":[{"id":"6a8a60adc388121a9d996242","name":"Suporte para notebook","slug":"suporte-notebook","description":"Suporte de alumínio para organizar a mesa.","priceCents":15990,"stock":12,"categories":["escritorio","acessorios"],"active":true,"createdAt":"2026-08-23T02:53:33.265Z","updatedAt":"2026-08-23T02:53:33.265Z"}],"page":1,"limit":5,"total":1,"pages":1}

Use o id devolvido pelo POST para alterar apenas o estoque. Depois, remova o produto. 204 No Content não tem corpo: o próprio status confirma a operação.

bash
curl -sS -X PATCH http://127.0.0.1:3019/products/6a8a60adc388121a9d996242 \
  -H 'content-type: application/json' -d '{"stock":10}'
curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE \
  http://127.0.0.1:3019/products/6a8a60adc388121a9d996242
{"id":"6a8a60adc388121a9d996242","name":"Suporte para notebook","slug":"suporte-notebook","description":"Suporte de alumínio para organizar a mesa.","priceCents":15990,"stock":10,"categories":["escritorio","acessorios"],"active":true,"createdAt":"2026-08-23T02:53:33.265Z","updatedAt":"2026-08-23T03:02:22.979Z"} 204

Com dois produtos de exemplo, o relatório agrupa categorias. Um produto pode contribuir para mais de um grupo porque $unwind abre o array categories.

bash
curl -sS http://127.0.0.1:3019/reports/categories
{"items":[{"products":2,"totalStock":19,"category":"escritorio","averagePriceCents":12490},{"products":1,"totalStock":12,"category":"acessorios","averagePriceCents":15990},{"products":1,"totalStock":7,"category":"perifericos","averagePriceCents":8990}]}

Entenda Number, centavos e Decimal128

JavaScript number usa ponto flutuante binário. Alguns decimais comuns não têm representação binária finita, então 0.1 + 0.2 não resulta exatamente em 0.3. Além disso, inteiros deixam de ser todos representáveis depois de Number.MAX_SAFE_INTEGER, que vale 9.007.199.254.740.991.

js
import { Decimal128 } from 'mongodb';

console.log(JSON.stringify({
  binaryFloat: 0.1 + 0.2,
  decimal128: Decimal128.fromString('0.30').toString(),
  maxSafeInteger: Number.MAX_SAFE_INTEGER,
  integersLosePrecisionAfterLimit:
    Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2,
}));
bash
npm run money:demo
{"binaryFloat":0.30000000000000004,"decimal128":"0.30","maxSafeInteger":9007199254740991,"integersLosePrecisionAfterLimit":true}

Neste catálogo, priceCents é inteiro, tem teto de dois bilhões e portanto fica muito abaixo do limite seguro. Essa estratégia funciona quando a menor unidade é fixa. Decimal128 é um tipo BSON decimal de 128 bits, com 34 dígitos decimais de precisão; ele é útil quando o domínio precisa preservar casas e escala decimal. A aplicação deve converter sua entrada por string e definir arredondamento, impostos e serialização. Trocar o tipo não decide essas regras de negócio automaticamente.

Teste o banco, o HTTP e o erro 500

Os testes usam o runner nativo do Node e Supertest. Eles abrem um database exclusivo, criam schema e índices, exercitam a aplicação sem reservar uma porta e removem o database no after(). Um teste grava diretamente um documento incompleto para provar que o MongoDB, não apenas a rota, devolve o código 121 de falha de validação.

ts
await assert.rejects(
  db.collection('products').insertOne({ name: 'Documento incompleto' }),
  (error: unknown) => error instanceof MongoServerError && error.code === 121,
);

Para provocar 500 sem adicionar uma vulnerabilidade à API, o teste injeta um ProductStore cuja listagem lança senha=nao-vazar. A resposta precisa ser genérica e o teste confirma que esse detalhe não aparece no corpo.

ts
const failingStore: ProductStore = {
  ping: () => store.ping(),
  create: (input) => store.create(input),
  list: async () => { throw new Error('senha=nao-vazar'); },
  findById: (id) => store.findById(id),
  update: (id, input) => store.update(id, input),
  delete: (id) => store.delete(id),
  reportByCategory: () => store.reportByCategory(),
};
const response = await request(createApp(failingStore, { error() {} }))
  .get('/products').expect(500);
assert.equal(response.body.error, 'Erro interno do servidor.');
assert.doesNotMatch(JSON.stringify(response.body), /senha/);

Execute os testes dentro da imagem Node 24, ligados ao contêiner MongoDB. A saída abaixo é a execução real completa.

bash
docker compose --profile test run --rm --build test
▶ API de produtos ✔ confirma saúde e conexão com o banco ✔ recusa JSON quebrado e campos inválidos com 400 ✔ cria um produto e mapeia slug duplicado para 409 ✔ busca, pagina e agrega categorias ✔ diferencia id inválido (400) de produto ausente (404) ✔ atualiza, remove e confirma a remoção ✔ mantém a validação no MongoDB mesmo fora da rota ✔ esconde detalhes inesperados e responde 500 ℹ tests 8 ℹ pass 8 ℹ fail 0 ℹ duration_ms 462.075791

O smoke test atravessa a imagem em execução pela rede: verifica saúde, cria, lista, atualiza, agrega, remove e confirma o 404 posterior. Ele testa menos casos que a suíte, mas prova que build, processo, porta e banco funcionam juntos.

bash
API_URL=http://api:3000 docker compose --profile test run --rm test npm run test:smoke
{"health":"ok","created":201,"listed":1,"updatedStock":5,"reportHasPapelaria":true,"deleted":204,"afterDelete":404}

Por fim, o audit examina a árvore travada no lockfile. Zero vulnerabilidades conhecidas não significa risco zero; significa apenas que o registro consultado não encontrou uma ocorrência conhecida nessa árvore e naquele momento.

bash
docker compose --profile test run --rm test npm audit --audit-level=high
found 0 vulnerabilities

O que ainda falta antes de produção

Este projeto é executável, mas não finge ser um deploy. O MongoDB local está sem autenticação. O bind em 127.0.0.1 reduz exposição no computador, porém não substitui controle de acesso. Em produção, habilite autenticação, crie um usuário com menor privilégio, use TLS, mantenha o banco em rede privada, guarde segredos fora do repositório e configure backups restauráveis.

Na API, acrescente autenticação e autorização, CORS conforme o cliente real, rate limiting, logs estruturados sem dados pessoais, métricas e rastreamento. Defina timeout, encerramento durante orquestração e limites de payload coerentes. O exemplo já usa JSON de 32 KB e oculta erros inesperados, mas esses controles são apenas uma parte da superfície.

Também revise índices com consultas reais e explain(). A busca com regex e a paginação por skip foram escolhas pedagógicas; um catálogo grande pode pedir busca dedicada e paginação por cursor. A modelagem de documentos ajuda a decidir quais dados pertencem ao mesmo agregado antes de otimizar as consultas.

Missão prática: adicione estoque baixo

Crie GET /reports/low-stock?limit=5. A rota deve aceitar um limite entre 1 e 20, listar apenas produtos ativos com stock menor ou igual a 5 e ordenar por estoque crescente, depois por _id. Adicione um teste com produtos de estoque 0, 5 e 6.

ts
const lowStock = await collection.find({ active: true, stock: { $lte: 5 } })
  .sort({ stock: 1, _id: 1 })
  .limit(limit)
  .toArray();

Seu critério de sucesso é objetivo: estoque 0 e 5 aparecem nessa ordem; estoque 6 não aparece; limit=0 devolve 400; a suíte continua com zero falhas. Depois, explique qual índice atende essa consulta e use explain('executionStats') para comparar antes e depois, em vez de assumir que o nome do índice prova ganho.

Quando terminar, remova somente os recursos deste laboratório. down -v apaga o contêiner e o volume devclub-api-mongodb_mongo-data; portanto não use esse comando num Compose que guarda dados importantes.

bash
docker compose down -v --remove-orphans
Container devclub-api-mongodb-api-1 Removed Container devclub-api-mongodb-mongo-1 Removed Volume devclub-api-mongodb_mongo-data Removed Network devclub-api-mongodb_default Removed

Agora você tem uma API pequena, mas com fronteiras que aparecem em aplicações reais: contrato HTTP, validação em runtime, regra no banco, índice único, paginação limitada, relatório agregado e testes que reproduzem os principais caminhos de erro. O próximo passo é fazer a missão, medir a consulta e então trocar uma escolha por vez, mantendo a saída observável como prova.

  • node.js
  • typescript
  • express
  • mongodb
  • api rest
  • docker
  • crud

Perguntas frequentes

Preciso usar Mongoose para criar uma API com MongoDB?
Não. Este projeto usa o driver oficial mongodb, que conecta, consulta, atualiza e agrega documentos diretamente. Um ODM pode acrescentar outras convenções, mas não é requisito para usar MongoDB com Node.js.
Por que guardar o preço em centavos?
Centavos inteiros evitam a imprecisão de somas decimais enquanto os valores permanecem dentro de Number.MAX_SAFE_INTEGER. Para outra escala, precisão decimal ou interoperabilidade financeira, avalie Decimal128.
A validação da rota substitui a validação do MongoDB?
Não. A rota devolve mensagens úteis ao cliente, enquanto o JSON Schema da coleção protege o banco contra gravações inválidas feitas por outro ponto de entrada. As duas barreiras têm responsabilidades diferentes.
Por que um slug repetido responde 409?
O índice unique impede a duplicação e o driver devolve o código 11000. A API converte esse conflito de estado em HTTP 409, sem expor detalhes do banco na resposta pública.
Este Docker Compose está pronto para produção?
Não. Ele foi desenhado para estudo local e mantém o MongoDB sem autenticação, embora publique as portas apenas em 127.0.0.1. Produção exige credenciais, menor privilégio, TLS, rede privada, backups, observabilidade e política de atualização.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.19.0, TypeScript 7.0.2, Express 5.2.1, MongoDB Node.js Driver 7.5.0, MongoDB 8.0.28 e Docker 29.5.3, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. Node.js — Previous Releases — nodejs.org
  2. TypeScript — Download — typescriptlang.org
  3. Express 5 — Error Handling — expressjs.com
  4. MongoDB — Node.js Driver — mongodb.com
  5. MongoDB — JSON Schema Validation — mongodb.com
  6. MongoDB — Unique Indexes — mongodb.com
  7. MongoDB Node.js Driver — Aggregation — mongodb.com
  8. MongoDB — Security Checklist — mongodb.com

Continue por aqui