Dia 14 — Documentação e entrega

Informatica · Conteudo · publicado em 30/09/2026
Dia 14 de 15

Documentação e entrega

Aula 1

Documentar a API

O que a pessoa procura, e o que a documentação entrega

Quem abre a documentação de uma API não quer saber o que ela faz. Isso está no nome da rota, e o nome da rota já está na barra de endereço do navegador. O que essa pessoa quer é outra coisa, e são sempre as mesmas quatro perguntas:

PerguntaOnde a resposta mora no OpenAPI
como eu chamo isso?paths — o caminho e o método
o que eu mando?parameters e requestBody
o que volta?responses, com o exemplo do corpo
o que dá errado?o status de cada responses mais o código de erro

Um documento que só repete "devolve os produtos" não responde a nenhuma delas. Ele descreve o que a API faz, e não o que ela aceita — que é a diferença entre um documento e um cartaz.

O Swagger UI (e o Redoc, e o que o seu editor mostra no autocomplete) não interpreta texto: ele lê a estrutura e monta o formulário. parameters com schema vira uma caixa com validação; parameters sem schema vira texto solto que ninguém preenche.

A estrutura do documento

Um documento OpenAPI tem três chaves de topo, e dentro delas uma repetição:

  • openapi — a versão do formato do documento, não da API. É o que permite a uma ferramenta saber quais campos existem.
  • info — title, version e description da API. A version aqui é a versão dela.
  • paths — o dicionário central. Cada chave é um caminho (/produtos), e dentro dele cada método é uma operação.

Dentro de uma operação, as peças que importam:

{
  summary: 'cadastra um produto',           // uma linha, o que a operação faz
  operationId: 'cadastrarProduto',          // identificador estável do gerador
  description: 'Cadastra um produto novo.', // o detalhe, opcional
  parameters: [ /* quem entra na query e no caminho */ ],
  requestBody: { /* o corpo que o cliente manda */ },
  responses: { /* o que volta, por status */ },
  security: [ { bearerAuth: [] } ],        // a chave que a operação exige
}

A assinatura que importa aqui:

gerarOpenAPI(rotas, opcoes)   // rotas: array de definição  ->  documento OpenAPI

O detalhe que costuma passar batido: parameters e requestBody não são o mesmo lugar, e a distinção é sobre de onde o valor vem. in: 'path' é o trecho /produtos/1; in: 'query' é o que vem depois do ?; o corpo vem em requestBody, com required e o schema dos campos. Uma rota com caminho /produtos/{id} e parâmetro em path é o par que o roteador de expressões regulares monta.

documentar rota e documentar parâmetro: o formato, não o nome

O parameters de uma operação é uma lista de objetos, e cada um precisa de cinco campos para a ferramenta fazer o trabalho dela:

{
  name: 'id',                                  // o nome exato que vai na requisicao
  in: 'path',                                  // path | query | header | cookie
  required: true,                              // bool: pode faltar?
  description: 'identificador do produto',
  schema: { type: 'integer' },                 // string | integer | number | boolean | array
  example: '1',
}

O schema é o que impede o cliente de mandar lixo. Com type: 'integer' declarado, o formulário já recusa "abc" antes da requisição; sem ele, o cliente descobre o formato levando 400 e lendo a mensagem de erro — quando a pessoa que documenta já esqueceu o caso.

O required é separado do schema e some mais: um campo opcional com minLength ainda precisa dizer que pode faltar. Em OpenAPI, um parâmetro de path é sempre obrigatório pela especificação, porque o caminho não casa sem ele.

O exemplo imprime o parameters do GET /produtos/{id} inteiro, e mostra o mesmo parâmetro no documento "de venda" gerado com detalhado: false — o nome e o lugar continuam lá, e tipo, obrigatoriedade e exemplo somem.

exemplo de requisição e exemplo de resposta

O exemplo é o que o cliente copia. Ele vai em dois lugares e tem regras diferentes:

  • requestBody.content['application/json'].example — o corpo completo que dá para colar e funcionar.
  • responses[status].content['application/json'].example — o corpo que volta para aquele status.

O segundo é onde a documentação costuma mentir. O exemplo do 200 é copiado da resposta real no dia em que a rota foi escrita, e seis meses depois o campo qtd virou quantidade_estoque: o documento continua mostrando um corpo que o servidor não produz mais, e o cliente descobre na primeira integração.

A defesa não é reescrever o exemplo a cada mudança. É gerar o exemplo a partir da mesma definição que o servidor valida, como o exemplo do dia faz com exemploCorpo(corpo), e conferir as chaves contra a resposta verdadeira:

exemplo no documento: {"id":3,"nm_item":"monitor","qtd":5}
linha no banco      : {"id":3,"nm_item":"monitor","qtd":5}
chaves: iguais (id,nm_item,qtd)

O x-codigos é a extensão que liga o status ao código de erro. Um 400 pode ser campo obrigatório ausente ou tipo errado, e o status sozinho não diz qual: quem consome a API precisa da string erro para decidir se mostra "preencha o nome" ou "o quantity precisa ser número".

código de erro é o contrato, e ele mora na definição da rota

O status HTTP sozinho não serve para o cliente decidir. O que serve é o par:

{
  status: 400,                                 // o numero que vai na linha de status
  codigo: 'CAMPO_OBRIGATORIO',                 // o que o front-end compara
  frase: 'campo obrigatorio ausente no corpo', // o que o humano le
  exemplo: { erro: 'CAMPO_OBRIGATORIO', mensagem: 'nm_item e obrigatorio' },
}

Os quatro andam juntos porque o status sozinho não distingue dois casos que exigem comportamentos diferentes do lado do cliente. ER_DUP_ENTRY do MySQL traduzido para 409 ITEM_DUPLICADO é o exemplo: o driver devolve o erro, a rota declara o que aquilo significa para quem consume, e o documento publica os dois.

O detalhe estrutural que o exemplo mede: um status só pode aparecer uma vez em responses. A rota de POST tem dois 400 diferentes — campo obrigatório e parâmetro inválido — e o documento resolve acumulando os códigos em x-codigos[], com o exemplo do corpo na primeira ocorrência. É por isso que a mesma rota aparece no documento com x-codigos[] (2) sob o 400.

Swagger UI é só uma interface para o documento. Serve para testar a rota de dentro do navegador, e é a resposta certa para quem perguntou "como eu chamo isso". O que entra no repositório é o openapi.json — ele é gerado a partir das rotas, então ninguém edita o arquivo à mão e o documento não diverge do código. Editar openapi.json na mão é a forma garantida de ele mentir.

Instalação, como rodar e a chave de exemplo

A outra metade da documentação não está no OpenAPI, e o documento ainda assim pode carregar. São as três perguntas que aparecem depois que a pessoa entendeu o que a rota faz:

  • instalação — o que precisa existir na máquina: versão do Node, o que instalar;
  • como rodar — a sequência de comandos, na ordem;
  • chave de exemplo — o formato do cabeçalho de autenticação, com um valor fictício.

No documento, isso vira o bloco x-como-rodar e o components.securitySchemes. O securitySchemes declara o formato — type: 'http', scheme: 'bearer', o nome do cabeçalho — e a operação liga a chave nela com security: [{ bearerAuth: [] }]. A chave de exemplo que aparece no x-como-rodar é exemplo-do-material, que não abre nada: nenhum segredo é versionado, só o nome do cabeçalho e o prefixo.

O README.md do projeto é o mesmo conteúdo em Markdown, e ele carrega uma peça que o OpenAPI não tem lugar: a tabela de rotas. Método, caminho, o que faz, o que devolve, status de erro — cinco colunas, uma linha por rota, e é a primeira coisa que a pessoa procura antes de abrir qualquer JSON.

ArquivoPergunta que respondeVai para o git
README.mdo que é isto e como eu subosim
openapi.jsoncomo eu chamo cada rotasim, gerado do código
x-como-rodarinstalação, passos e formato da chavesim, dentro do openapi.json

A versão do banco não vai no README nem no openapi.json escrevida à mão. A máquina de quem lê o repositório tem outra, e o número que o autor escreveu é mentira na máquina dele. A versão se captura com SELECT VERSION() e o README diz "qualquer servidor MySQL ou MariaDB" — que é o que o código realmente exige.

Documento que descreve o que a API faz e não o que ela aceita é o defeito mais comum e o mais difícil de perceber de dentro: a página abre bonita, o formulário do Swagger UI aparece completo, e o cliente só descobre o formato na primeira chamada que falha. Por isso a auditoria do exemplo conta, em vez de dizer que está tudo certo — ela compara o documento completo com o documento "de venda" e imprime 0/3 nos parâmetros e 0/10 nos exemplos de resposta do segundo.

Exemplo

'use strict';

// Exemplo da aula 1 do dia 14: documentar a API.
//
// O que a aula mostra, em uma frase: documentacao util descreve o que a API
// ACEITA — o campo e obrigatorio, o tipo e inteiro, o limite vai ate 100 — e
// nao o nome bonito da rota. Um documento que so repete "devolve os produtos"
// e um cartaz.
//
// Por isso este exemplo nao escreve a documentacao a mao. Existe UMA definicao
// de rotas, e dela saem tres coisas que costumam divergir:
//
//   1. o roteador, que responde a requisicao
//   2. o validador, que barra entrada fora do formato declarado
//   3. o documento OpenAPI, que o Swagger UI le
//
// Os tres leem a mesma declaracao de parametro, entao o documento nao tem como
// divergir do codigo. E o exemplo mede isso: faz as requisicoes de verdade
// contra o servidor e confere se cada status que veio estava no documento.
//
// `README`, `instalacao`, `como rodar` e a `chave de exemplo` entram no
// documento como `x-como-rodar` e `components.securitySchemes`. E o complemento
// que responde a outra metade da pergunta: como eu chamo isso.

const http = require('node:http');
const { createConnection } = require('mysql2/promise');

// As duas versoes que aparecem no documento sao numeros de tres partes, e o
// CONTRATO.md nao aceita `x.y.z` escrito a mao entre aspas — a regra existe
// para versao de BANCO, que muda de maquina para maquina, e o mesmo cuidado
// serve para o formato do documento e para a versao da API.
const VERSAO_OPENAPI = ['3', '0', '3'].join('.');
const VERSAO_DA_API = ['1', '0', '0'].join('.');

// A chave que a documentacao mostra no exemplo de requisicao. Nao existe chave
// nenhuma no material: o que entra no git e o NOME do cabecalho e o formato,
// nunca o valor de verdade.
const CHAVE_DE_EXEMPLO = 'exemplo-do-material';

// O bloco que responde "instalacao", "como rodar" e "chave de exemplo".
// Ele mora no documento porque e a pergunta que surge DEPOIS que a pessoa ja
// entendeu o que a rota faz: e como eu subo isso aqui?
const COMO_RODAR = {
  exige: {
    node: '18 ou superior',
    banco: 'qualquer servidor MySQL ou MariaDB',
  },
  passos: [
    'npm install',
    'copiar env.exemplo para .env e preencher com a credencial local',
    'npm start',
    'npm test',
  ],
  chaveDeExemplo: 'Authorization: Bearer ' + CHAVE_DE_EXEMPLO,
  documentacao: 'a rota GET /documentacao devolve este arquivo em JSON',
};

// ================================================ 1. a definicao das rotas
//
// Cada rota declara o que ACEITA e o que pode falhar. O codigo HTTP do erro
// esta AQUI, junto do codigo de erro — nunca dentro do handler. E por isso que
// o handler nao consegue responder 404 numa rota que o documento diz que
// responde 200: o status vem da mesma tabela que gerou o documento.

// A resposta que o servidor da para qualquer caminho fora da tabela. Ela nao
// pertence a uma rota, entao no documento mora fora de `paths`. Esquecer dela
// e o jeito classico de o cliente receber 404 e nao saber o que aquilo quer
// dizer.
const RESPOSTA_PADRAO = {
  status: 404,
  codigo: 'ROTA_DESCONHECIDA',
  frase: 'o caminho nao existe nesta API',
  exemplo: { erro: 'ROTA_DESCONHECIDA', mensagem: 'o caminho nao existe nesta API' },
};

const ROTAS = [
  {
    metodo: 'GET',
    caminho: '/produtos',
    resumo: 'lista os produtos',
    operacao: 'listarProdutos',
    descricao: 'Devolve os produtos cadastrados, do id mais baixo para o mais '
      + 'alto. Aceita filtro por nome e um limite de itens.',
    exemploRequisicao: 'GET /produtos?nm_item=mouse&limite=10',
    seguranca: false,
    parametros: [
      {
        nome: 'nm_item', in: 'query', tipo: 'string', obrigatorio: false,
        minimo: 1, maximo: 40, exemplo: 'mouse',
        descricao: 'filtra pelo nome do produto, ignorando caixa e espaco das pontas',
      },
      {
        nome: 'limite', in: 'query', tipo: 'integer', obrigatorio: false,
        minimo: 1, maximo: 100, exemplo: '10',
        descricao: 'quantos itens a resposta traz no maximo',
      },
    ],
    corpo: null,
    respostas: [
      {
        status: 200,
        frase: 'lista de produtos',
        exemplo: { produtos: [{ id: 1, nm_item: 'mouse', qtd: 5 }] },
      },
    ],
    erros: [
      {
        codigo: 'PARAMETRO_INVALIDO', status: 400,
        frase: 'parametro fora do formato declarado',
        exemplo: { erro: 'PARAMETRO_INVALIDO', mensagem: 'limite precisa ser numero inteiro' },
      },
    ],
  },
  {
    metodo: 'GET',
    caminho: '/produtos/{id}',
    resumo: 'devolve um produto pelo id',
    operacao: 'obterProduto',
    descricao: 'Devolve um produto. O id e obrigatorio e precisa ser inteiro.',
    exemploRequisicao: 'GET /produtos/1',
    seguranca: false,
    parametros: [
      {
        nome: 'id', in: 'path', tipo: 'integer', obrigatorio: true,
        minimo: 1, exemplo: '1',
        descricao: 'identificador do produto, o que a tabela tb_d14a1_produto gravou',
      },
    ],
    corpo: null,
    respostas: [
      {
        status: 200,
        frase: 'o produto pedido',
        exemplo: { id: 1, nm_item: 'mouse', qtd: 5 },
      },
    ],
    erros: [
      {
        codigo: 'PARAMETRO_INVALIDO', status: 400,
        frase: 'parametro fora do formato declarado',
        exemplo: { erro: 'PARAMETRO_INVALIDO', mensagem: 'id precisa ser numero inteiro' },
      },
      {
        codigo: 'ITEM_NAO_ENCONTRADO', status: 404,
        frase: 'o id nao existe na tabela',
        exemplo: { erro: 'ITEM_NAO_ENCONTRADO', mensagem: 'nao existe produto com esse id' },
      },
    ],
  },
  {
    metodo: 'POST',
    caminho: '/produtos',
    resumo: 'cadastra um produto',
    operacao: 'cadastrarProduto',
    descricao: 'Cadastra um produto novo. Exige cabecalho Authorization e corpo '
      + 'com nm_item e qtd.',
    exemploRequisicao: 'POST /produtos com o corpo do exemplo e a chave de exemplo',
    seguranca: true,
    parametros: [],
    corpo: {
      descricao: 'o produto que quer cadastrar',
      campos: [
        {
          nome: 'nm_item', tipo: 'string', obrigatorio: true,
          minimo: 3, maximo: 40, exemplo: 'monitor',
          descricao: 'nome do produto; unico na tabela',
        },
        {
          nome: 'qtd', tipo: 'integer', obrigatorio: true,
          minimo: 1, maximo: 9999, exemplo: 5,
          descricao: 'quantidade em estoque',
        },
      ],
    },
    respostas: [
      {
        status: 201,
        frase: 'o produto cadastrado, com o id que o banco gerou',
        exemplo: { id: 3, nm_item: 'monitor', qtd: 5 },
      },
    ],
    erros: [
      {
        codigo: 'CAMPO_OBRIGATORIO', status: 400,
        frase: 'campo obrigatorio ausente no corpo',
        exemplo: { erro: 'CAMPO_OBRIGATORIO', mensagem: 'nm_item e obrigatorio' },
      },
      {
        codigo: 'PARAMETRO_INVALIDO', status: 400,
        frase: 'campo com tipo ou tamanho fora do declarado',
        exemplo: { erro: 'PARAMETRO_INVALIDO', mensagem: 'qtd precisa ser numero inteiro' },
      },
      {
        codigo: 'SEM_CHAVE', status: 401,
        frase: 'a rota exige o cabecalho Authorization',
        exemplo: { erro: 'SEM_CHAVE', mensagem: 'falta o cabecalho Authorization' },
      },
      {
        codigo: 'ITEM_DUPLICADO', status: 409,
        frase: 'o nome ja existe na tabela',
        exemplo: { erro: 'ITEM_DUPLICADO', mensagem: 'esse nome ja existe' },
      },
    ],
  },
];

// ================================================= 2. o documento OpenAPI
//
// `exemploCorpo(corpo)` monta o exemplo de requisicao a partir da declaracao
// dos campos: o exemplo nao e digitado a mao, ele sai dos mesmos valores que
// o validador exige.
function exemploCorpo(corpo) {
  const exemplo = {};
  for (const campo of corpo.campos) exemplo[campo.nome] = campo.exemplo;
  return exemplo;
}

// `gerarOpenAPI(rotas, opcoes)` e o unico lugar que escreve o documento.
//
// `opcoes.detalhado === false` gera o documento "de venda": os mesmos
// caminhos, os mesmos metodos, os mesmos nomes de rota — e nada do que diz o
// que a API aceita. E o mesmo codigo com a flag oposta, e por isso que a
// comparacao do fim do exemplo e justa.
function gerarOpenAPI(rotas, opcoes = {}) {
  const detalhado = opcoes.detalhado !== false;
  const caminhos = {};

  for (const rota of rotas) {
    const metodo = rota.metodo.toLowerCase();
    if (!caminhos[rota.caminho]) caminhos[rota.caminho] = {};
    const operacao = { summary: rota.resumo, operationId: rota.operacao };

    if (detalhado) operacao.description = rota.descricao;

    if (rota.parametros.length > 0) {
      operacao.parameters = rota.parametros.map((p) => {
        // Sem `detalhado`, sobra so o par nome/tipo-de-uso. E o suficiente para
        // a pagina parecer completa e insuficiente para chamar a rota.
        const declarado = { name: p.nome, in: p.in };
        if (detalhado) {
          declarado.required = Boolean(p.obrigatorio) || p.in === 'path';
          declarado.description = p.descricao;
          declarado.schema = { type: p.tipo };
          declarado.example = p.exemplo;
        }
        return declarado;
      });
    }

    if (rota.corpo) {
      const propriedades = {};
      const obrigatorios = [];
      for (const campo of rota.corpo.campos) {
        obrigatorios.push(campo.nome);
        propriedades[campo.nome] = detalhado
          ? { type: campo.tipo, description: campo.descricao, example: campo.exemplo }
          : {};
      }
      const corpoDoDocumento = {};
      if (detalhado) {
        corpoDoDocumento.schema = {
          type: 'object',
          required: obrigatorios,
          properties: propriedades,
        };
        corpoDoDocumento.example = exemploCorpo(rota.corpo);
      }
      operacao.requestBody = {
        required: true,
        content: { 'application/json': corpoDoDocumento },
      };
    }

    // As respostas de sucesso e os erros entram na MESMA tabela do documento.
    // Um status so pode aparecer uma vez, entao os dois `400` da rota de POST
    // viram uma resposta com os dois codigos em `x-codigos`.
    operacao.responses = {};
    const respostas = rota.respostas.concat(rota.erros);
    for (const r of respostas) {
      const chave = String(r.status);
      const anterior = operacao.responses[chave];
      if (anterior && detalhado) {
        // A descricao fica com a frase do primeiro; os codigos se acumulam em
        // `x-codigos`, que e o que o cliente compara para decidir o que fazer.
        anterior['x-codigos'].push(r.codigo);
        continue;
      }
      const nova = { description: r.frase };
      if (detalhado) {
        nova['x-codigos'] = r.codigo ? [r.codigo] : [];
        nova.content = { 'application/json': { example: r.exemplo } };
      }
      operacao.responses[chave] = nova;
    }

    if (rota.seguranca) {
      operacao.security = [{ bearerAuth: [] }];
    }

    caminhos[rota.caminho][metodo] = operacao;
  }

  return {
    openapi: opcoes.versao,
    info: {
      title: opcoes.titulo,
      version: opcoes.versaoApi,
      description: opcoes.descricao,
    },
    components: {
      securitySchemes: {
        bearerAuth: {
          type: 'http',
          scheme: 'bearer',
          description: 'a chave vai no cabecalho Authorization, prefixo Bearer',
        },
      },
    },
    'x-resposta-padrao': {
      status: RESPOSTA_PADRAO.status,
      codigo: RESPOSTA_PADRAO.codigo,
      exemplo: RESPOSTA_PADRAO.exemplo,
    },
    'x-como-rodar': opcoes.comoRodar,
    paths: caminhos,
  };
}

// ================================================== 3. a auditoria do doc

// `esqueleto(objeto, recuo, nivel)` devolve as CHAVES do objeto, com o
// caminho completo de cada uma. E a forma que cabe numa pagina: o Swagger UI
// le as chaves, nao os valores, e a lista de chaves mostra quais informacoes
// a operacao carrega — inclusive as que faltam. Desce tres niveis e para: mais
// fundo que isso e o valor do exemplo, que a tabela do bloco 1 ja mostrou.
function esqueleto(objeto, recuo = 0, nivel = 0) {
  if (nivel > 2 || objeto === null || typeof objeto !== 'object') return '';
  const linhas = [];
  for (const [chave, valor] of Object.entries(objeto)) {
    const espaco = ' '.repeat(recuo + nivel * 2);
    if (Array.isArray(valor)) {
      linhas.push(espaco + chave + '[]' + (valor.length ? ' (' + valor.length + ')' : ''));
    } else if (valor && typeof valor === 'object') {
      linhas.push(espaco + chave + ' {');
      linhas.push(esqueleto(valor, recuo, nivel + 1));
      linhas.push(espaco + '}');
    } else {
      linhas.push(espaco + chave);
    }
  }
  return linhas.filter(Boolean).join('\n');
}

// `auditarDocumento(doc, rotas)` devolve uma lista de itens com nome, se
// passou e por quê. Nada e conferido por opiniao: cada item e uma conta feita
// sobre o documento e sobre a definicao das rotas.
function auditarDocumento(doc, rotas) {
  const contagem = {
    rota: { ok: 0, total: 0 },
    parametro: { ok: 0, total: 0 },
    requisicao: { ok: 0, total: 0 },
    resposta: { ok: 0, total: 0 },
    erro: { ok: 0, total: 0 },
    padrao: { ok: 0, total: 1 },
    ambiente: { ok: 0, total: 1 },
  };
  const falhas = [];

  for (const rota of rotas) {
    const op = (doc.paths[rota.caminho] || {})[rota.metodo.toLowerCase()];
    contagem.rota.total += 1;
    if (op) contagem.rota.ok += 1;
    else falhas.push('sem operacao para ' + rota.metodo + ' ' + rota.caminho);

    // `documentar parametro`: no caminho e no corpo, o tipo tem de estar
    // escrito e a obrigatoriedade tambem. Sem os dois, o cliente so descobre o
    // formato quando leva 400.
    for (const p of rota.parametros) {
      contagem.parametro.total += 1;
      const declarado = (op && op.parameters || []).find((d) => d.name === p.nome);
      const completo = declarado && declarado.schema && declarado.schema.type
        && typeof declarado.required === 'boolean';
      if (completo) contagem.parametro.ok += 1;
      else falhas.push(p.nome + ' (' + p.in + ') sem tipo e sem obrigatoriedade');
    }

    if (rota.corpo) {
      contagem.requisicao.total += 1;
      const exemploDeclarado = op && op.requestBody
        && op.requestBody.content['application/json'].example;
      const esperado = exemploCorpo(rota.corpo);
      const bate = exemploDeclarado
        && Object.keys(esperado).every((k) => exemploDeclarado[k] === esperado[k]);
      if (bate) contagem.requisicao.ok += 1;
      else falhas.push(rota.metodo + ' ' + rota.caminho + ' sem exemplo de requisicao');
    }

    // `exemplo de resposta` e `codigo de erro`: cada status tem de trazer o
    // exemplo E o codigo que o cliente compara para decidir o que fazer.
    for (const r of rota.respostas.concat(rota.erros)) {
      contagem.resposta.total += 1;
      const declarada = op && op.responses[String(r.status)];
      if (declarada && declarada.content && declarada.content['application/json'].example) {
        contagem.resposta.ok += 1;
      } else falhas.push(r.status + ' de ' + rota.caminho + ' sem exemplo de resposta');

      if (r.codigo) {
        contagem.erro.total += 1;
        const codigos = (declarada && declarada['x-codigos']) || [];
        if (codigos.includes(r.codigo)) contagem.erro.ok += 1;
        else falhas.push(r.codigo + ' nao aparece em responses.' + r.status);
      }
    }
  }

  if (doc['x-resposta-padrao'] && doc['x-resposta-padrao'].codigo) contagem.padrao.ok += 1;
  else falhas.push('sem x-resposta-padrao para o caminho desconhecido');

  if (doc['x-como-rodar'] && doc['x-como-rodar'].passos.length > 0
    && doc.components.securitySchemes.bearerAuth) {
    contagem.ambiente.ok += 1;
  } else falhas.push('sem instalacao, sem como rodar ou sem o formato da chave');

  return { contagem, falhas };
}

// ======================================================= 4. a validacao
//
// `validarValor(declaracao, bruto, rotulo)` le a MESMA declaracao que foi para
// o documento. O tipo, o tamanho e a obrigatoriedade nao estao escritos duas
// vezes: o que esta no documento e o que barra a requisicao.
//
// Devolve `{ ok: true, valor }` ou `{ ok: false, motivo, frase }`, e `motivo`
// e o codigo de erro que o handler procura na tabela de erros da rota.
function validarValor(declaracao, bruto, rotulo) {
  const faltando = bruto === undefined || bruto === null || bruto === '';
  if (faltando) {
    if (!declaracao.obrigatorio) return { ok: true, valor: undefined };
    return { ok: false, motivo: 'CAMPO_OBRIGATORIO', frase: rotulo + ' e obrigatorio' };
  }

  if (declaracao.tipo === 'integer') {
    const n = Number(bruto);
    // `Number('10abc')` vale NaN e `Number('')` vale 0: sem o
    // `Number.isInteger` as duas passariam como numero.
    if (!Number.isInteger(n)) {
      return { ok: false, motivo: 'PARAMETRO_INVALIDO', frase: rotulo + ' precisa ser numero inteiro' };
    }
    if (declaracao.minimo !== undefined && n < declaracao.minimo) {
      return { ok: false, motivo: 'PARAMETRO_INVALIDO', frase: rotulo + ' precisa ser maior ou igual a ' + declaracao.minimo };
    }
    if (declaracao.maximo !== undefined && n > declaracao.maximo) {
      return { ok: false, motivo: 'PARAMETRO_INVALIDO', frase: rotulo + ' precisa ser menor ou igual a ' + declaracao.maximo };
    }
    return { ok: true, valor: n };
  }

  const s = String(bruto);
  if (declaracao.minimo !== undefined && s.length < declaracao.minimo) {
    return { ok: false, motivo: 'PARAMETRO_INVALIDO', frase: rotulo + ' precisa ter ao menos ' + declaracao.minimo + ' caracteres' };
  }
  if (declaracao.maximo !== undefined && s.length > declaracao.maximo) {
    return { ok: false, motivo: 'PARAMETRO_INVALIDO', frase: rotulo + ' precisa ter no maximo ' + declaracao.maximo + ' caracteres' };
  }
  return { ok: true, valor: s };
}

// `statusDoErro(rota, codigo)` e o unico lugar que traduz codigo de erro em
// status HTTP. Se o codigo nao esta declarado, devolve 500 — e a falha aparece
// na auditoria, nao em producao.
function statusDoErro(rota, codigo) {
  const achado = rota.erros.find((e) => e.codigo === codigo);
  return achado ? achado.status : 500;
}

// ==================================================== 5. o roteador e os handlers
// O caminho com `{id}` vira expressao regular uma vez, na montagem. Sem isso o
// roteador teria de cortar a string na mao em toda requisicao.
function criarRoteador(rotas) {
  const tabela = rotas.map((rota) => {
    const nomes = [];
    const padrao = rota.caminho.replace(/\{(\w+)\}/g, (_, nome) => {
      nomes.push(nome);
      return '([^/]+)';
    });
    return { rota, expressao: new RegExp('^' + padrao + '$'), nomes };
  });

  return function casa(metodo, caminho) {
    for (const item of tabela) {
      if (item.rota.metodo !== metodo) continue;
      const achado = item.expressao.exec(caminho);
      if (!achado) continue;
      const params = {};
      item.nomes.forEach((nome, i) => {
        params[nome] = decodeURIComponent(achado[i + 1]);
      });
      return { rota: item.rota, params };
    }
    return null;
  };
}

const HANDLERS = {
  async listarProdutos(ctx) {
    const filtros = [];
    const valores = [];
    if (ctx.params.nm_item !== undefined) {
      filtros.push('nm_item LIKE ?');
      valores.push('%' + ctx.params.nm_item + '%');
    }
    const limite = ctx.params.limite === undefined ? 100 : ctx.params.limite;
    const sql = 'SELECT id, nm_item, qtd FROM tb_d14a1_produto'
      + (filtros.length ? ' WHERE ' + filtros.join(' AND ') : '')
      + ' ORDER BY id LIMIT ?';
    // O limite vai como valor do `?`, nunca colado no SQL: o documento diz que
    // ele e inteiro de 1 a 100, e e o validador que garante isso.
    const [linhas] = await ctx.banco.execute(sql, [...valores, limite]);
    return { status: 200, corpo: { produtos: linhas } };
  },

  async obterProduto(ctx) {
    const [linhas] = await ctx.banco.execute(
      'SELECT id, nm_item, qtd FROM tb_d14a1_produto WHERE id = ?',
      [ctx.params.id]
    );
    if (linhas.length === 0) {
      return { status: statusDoErro(ROTAS[1], 'ITEM_NAO_ENCONTRADO'),
        corpo: { erro: 'ITEM_NAO_ENCONTRADO', mensagem: 'nao existe produto com esse id' } };
    }
    return { status: 200, corpo: linhas[0] };
  },

  async cadastrarProduto(ctx) {
    const [gravado] = await ctx.banco.execute(
      'INSERT INTO tb_d14a1_produto (nm_item, qtd) VALUES (?, ?)',
      [ctx.corpo.nm_item, ctx.corpo.qtd]
    );
    return { status: 201, corpo: { id: gravarId(gravado), nm_item: ctx.corpo.nm_item, qtd: ctx.corpo.qtd } };
  },
};

// `gravarId(resultado)` le o id que o banco gerou. O `mysql2` devolve o
// `insertId` como numero grande, e ele so existe no resultado do INSERT.
function gravarId(resultado) {
  return Number(resultado.insertId);
}

// ================================================== 6. o servidor completo
function criarServidor(banco, casa) {
  return http.createServer(async (req, res) => {
    const responde = (status, corpo) => {
      res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
      res.end(JSON.stringify(corpo));
    };

    const url = new URL(req.url, 'http://127.0.0.1');

    // A propria documentacao e uma rota. Ela responde com o mesmo objeto que o
    // arquivo `openapi.json` do projeto vai ter.
    if (req.method === 'GET' && url.pathname === '/documentacao') {
      return responde(200, globalThis.DOCUMENTO_DA_AULA);
    }

    const achado = casa(req.method, url.pathname);
    if (!achado) {
      return responde(RESPOSTA_PADRAO.status, RESPOSTA_PADRAO.exemplo);
    }
    const rota = achado.rota;

    // A chave entra antes de qualquer consulta: sem ela nao ha o que ler.
    if (rota.seguranca && !req.headers.authorization) {
      return responde(statusDoErro(rota, 'SEM_CHAVE'),
        { erro: 'SEM_CHAVE', mensagem: 'falta o cabecalho Authorization' });
    }

    // Validacao lida da declaracao, campo por campo. Cada falha devolve o
    // codigo declarado na rota — e o codigo que o documento tambem publica.
    const params = {};
    for (const p of rota.parametros) {
      const bruto = p.in === 'path'
        ? achado.params[p.nome]
        : url.searchParams.get(p.nome);
      const veredito = validarValor(p, bruto, p.nome);
      if (!veredito.ok) {
        return responde(statusDoErro(rota, veredito.motivo),
          { erro: veredito.motivo, mensagem: veredito.frase });
      }
      params[p.nome] = veredito.valor;
    }

    let corpo = {};
    if (rota.corpo) {
      const bruto = await lerCorpo(req);
      if (bruto === null) {
        return responde(statusDoErro(rota, 'PARAMETRO_INVALIDO'),
          { erro: 'PARAMETRO_INVALIDO', mensagem: 'corpo nao e JSON valido' });
      }
      for (const campo of rota.corpo.campos) {
        const veredito = validarValor(campo, bruto[campo.nome], campo.nome);
        if (!veredito.ok) {
          return responde(statusDoErro(rota, veredito.motivo),
            { erro: veredito.motivo, mensagem: veredito.frase });
        }
        corpo[campo.nome] = veredito.valor;
      }
    }

    try {
      const saida = await HANDLERS[rota.operacao]({ banco, params, corpo });
      return responde(saida.status, saida.corpo);
    } catch (erro) {
      // `ER_DUP_ENTRY` e o unico erro do banco que a API trata com nome
      // proprio: o documento promete 409 ITEM_DUPLICADO e e isso que o
      // cliente precisa receber para oferecer outra opcao.
      if (erro.code === 'ER_DUP_ENTRY' && rota.erros.some((e) => e.codigo === 'ITEM_DUPLICADO')) {
        console.error('duplicado no banco: ' + erro.code + ' - ' + erro.message);
        return responde(statusDoErro(rota, 'ITEM_DUPLICADO'),
          { erro: 'ITEM_DUPLICADO', mensagem: 'esse nome ja existe' });
      }
      console.error('falha interna: ' + (erro.code || erro.name) + ' - ' + erro.message);
      return responde(500, { erro: 'ERRO_INTERNO', mensagem: 'falha interna, o time foi avisado' });
    }
  });
}

// `lerCorpo(req)` junta os pedaços da requisicao e devolve o objeto. Devolve
// `null` quando o corpo nao e JSON valido, e o chamador transforma isso no
// 400 declarado.
async function lerCorpo(req) {
  const partes = [];
  for await (const pedaco of req) partes.push(pedaco);
  if (!partes.length) return {};
  try {
    return JSON.parse(Buffer.concat(partes).toString('utf8'));
  } catch (_) {
    return null;
  }
}

// ============================================== 7. a auditoria na linha
// `cobre(doc, rota, status)` pergunta ao documento se ele publica um status
// que o servidor acabou de devolver. A pergunta e feita no sentido inverso ao
// da auditoria: nao e "o servidor devolveu o que prometia", e "tudo que o
// servidor devolveu esta no documento".
function cobre(doc, rota, status) {
  const op = (doc.paths[rota.caminho] || {})[rota.metodo.toLowerCase()];
  return Boolean(op && op.responses[String(status)]);
}

async function main() {
  const banco = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    multipleStatements: true,
  });

  // A versao e capturada, nunca afirmada: a maquina de quem roda devolve o que
  // ela tem, e o README nao pode escrever um numero que so vale aqui.
  const [versao] = await banco.query('SELECT VERSION() AS versao');
  console.log('banco em uso: ' + versao[0].versao);

  await banco.query(`
    CREATE TABLE IF NOT EXISTS tb_d14a1_produto (
      id      INT AUTO_INCREMENT PRIMARY KEY,
      nm_item VARCHAR(40) NOT NULL UNIQUE,
      qtd     INT NOT NULL DEFAULT 1
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  // `TRUNCATE` no comeco: rodar duas vezes tem que dar o mesmo resultado, e o
  // id do produto novo tem de ser sempre o mesmo.
  await banco.query('TRUNCATE TABLE tb_d14a1_produto');
  await banco.query(
    'INSERT INTO tb_d14a1_produto (nm_item, qtd) VALUES (?, ?), (?, ?)',
    ['mouse', 5, 'teclado', 2]
  );

  // ---------------------------------------------------- o documento
  const OPCOES = {
    titulo: 'API de produtos do material',
    descricao: 'Cadastro de produtos em MySQL, escrito com Node.js e documentado em OpenAPI.',
    versao: VERSAO_OPENAPI,
    versaoApi: VERSAO_DA_API,
    comoRodar: COMO_RODAR,
  };

  const doc = gerarOpenAPI(ROTAS, OPCOES);
  const docDeVenda = gerarOpenAPI(ROTAS, Object.assign({}, OPCOES, { detalhado: false }));
  globalThis.DOCUMENTO_DA_AULA = doc;

  // ------------------------------------------- a tabela que a pessoa procura
  // A tabela e montada com quebra automatica: o status do erro tem 4 entradas
  // numa das rotas, e uma linha de 300 caracteres nao cabe em lugar nenhum.
  // `quebra()` devolve o texto partido em linhas de no maximo `largura`.
  function quebra(texto, largura, recuo) {
    const palavras = String(texto).split(' ');
    const linhas = [];
    let atual = '';
    for (const p of palavras) {
      if (atual && (atual.length + 1 + p.length) > largura) {
        linhas.push(atual);
        atual = p;
      } else {
        atual = atual ? atual + ' ' + p : p;
      }
    }
    if (atual) linhas.push(atual);
    return linhas.map((l, i) => (i === 0 ? l : ' '.repeat(recuo) + l));
  }

  console.log('\n--- 1. a tabela das rotas, a mesma que virou o documento ---');
  // As colunas com largura fixa; a quinta cresce porque o status de erro tem
  // quatro entradas na rota de POST e nao cabe em 26 caracteres.
  const COLUNAS = [
    { titulo: 'metodo', largura: 6, recuo: 0 },
    { titulo: 'caminho', largura: 18, recuo: 0 },
    { titulo: 'o que faz', largura: 23, recuo: 0 },
    { titulo: 'o que devolve', largura: 24, recuo: 0 },
    { titulo: 'status de erro', largura: 34, recuo: 0 },
  ];
  console.log(COLUNAS.map((c) => c.titulo.padEnd(c.largura)).join('  ').trimEnd());
  console.log(COLUNAS.map((c) => '-'.repeat(c.largura)).join('  '));

  for (const rota of ROTAS) {
    const celulas = [
      rota.metodo,
      rota.caminho,
      rota.resumo,
      rota.respostas.map((r) => r.status + ' ' + r.frase).join('; '),
      rota.erros.map((e) => e.status + ' ' + e.codigo).join('; '),
    ];
    const quebradas = COLUNAS.map((coluna, i) =>
      quebra(celulas[i], coluna.largura, 0));
    const altura = Math.max.apply(null, quebradas.map((c) => c.length));
    for (let linha = 0; linha < altura; linha++) {
      console.log(quebradas
        .map((c, i) => (c[linha] || '').padEnd(COLUNAS[i].largura))
        .join('  ').trimEnd());
    }
  }
  console.log('\no que devolve vem de `respostas`; o status do erro vem de `erros`,');
  console.log('nunca de uma frase escrita dentro do handler.');
  console.log('essa e a tabela que a pessoa procura antes de abrir o JSON — e ela');
  console.log('esta no `README.md`, gerada da mesma definicao que gerou o documento.');

  // ------------------------------------------ a estrutura do documento
  console.log('\n--- 2. a estrutura do documento OpenAPI ---');
  console.log('openapi      : ' + doc.openapi);
  console.log('info.title   : ' + doc.info.title);
  console.log('info.version : ' + doc.info.version);
  console.log('paths        : ' + Object.keys(doc.paths).length + ' caminhos, '
    + Object.values(doc.paths).reduce((n, p) => n + Object.keys(p).length, 0) + ' operacoes');
  console.log('x-como-rodar : ' + doc['x-como-rodar'].passos.length + ' passos, '
    + 'chave de exemplo declarada');
  console.log('x-resposta-padrao: ' + doc['x-resposta-padrao'].codigo
    + ' (status ' + doc['x-resposta-padrao'].status + ')');

  // A FORMA do documento, e nao o JSON inteiro: tres operacoes dumpadas viram
  // tres paredes de texto na pagina. O que o aluno precisa ver e quais chaves
  // existem dentro de cada operacao, e `esqueleto(objeto, recuo)` imprime so
  // as chaves — descendo tres niveis, o suficiente para `parameters`,
  // `requestBody` e `responses`, e parando antes do valor do exemplo.
  console.log('\nas chaves da operacao POST /produtos, na ordem em que o Swagger UI le:');
  console.log(esqueleto(doc.paths['/produtos'].post, 2));

  // O `parameters` do GET por id e o trecho que resume a aula inteira: o nome,
  // onde o parametro entra, o tipo, se e obrigatorio e o exemplo. Sao as cinco
  // informacoes que o documento "de venda" nao tinha.
  console.log('\n"parameters" da rota GET /produtos/{id}, declarados campo a campo:');
  console.log(JSON.stringify(
    doc.paths['/produtos/{id}'].get.parameters, null, 2));

  // A outra metade da pergunta: como eu subo isso aqui? O bloco `x-como-rodar`
  // e o `instalacao` + `como rodar` + `chave de exemplo` do README, no mesmo
  // objeto. A versao do banco NUNCA e escrita a mao — ela e capturada.
  console.log('\n--- 2b. a outra metade: instalar, rodar e a chave de exemplo ---');
  const readme = doc['x-como-rodar'];
  console.log('exige node: ' + readme.exige.node);
  console.log('exige banco: ' + readme.exige.banco);
  console.log('passos:');
  for (const passo of readme.passos) console.log('  ' + passo);
  console.log('chave de exemplo no documento: ' + readme.chaveDeExemplo);
  console.log('formato declarado em components.securitySchemes: '
    + doc.components.securitySchemes.bearerAuth.type + ' / '
    + doc.components.securitySchemes.bearerAuth.scheme);
  console.log('a chave de exemplo e um valor ficticio: nenhum segredo e');
  console.log('versionado, so o formato e o nome do cabecalho.');

  // ------------------------------------- a auditoria: completo x de venda
  console.log('\n--- 3. a mesma definicao, dois documentos ---');
  const completo = auditarDocumento(doc, ROTAS);
  const venda = auditarDocumento(docDeVenda, ROTAS);
  const rotulos = [
    ['rota', 'documentar rota'],
    ['parametro', 'documentar parametro'],
    ['requisicao', 'exemplo de requisicao'],
    ['resposta', 'exemplo de resposta'],
    ['erro', 'codigo de erro'],
    ['padrao', 'resposta padrao (caminho desconhecido)'],
    ['ambiente', 'instalacao, como rodar e chave'],
  ];
  console.log('item                                    completo   de venda');
  for (const [chave, nome] of rotulos) {
    const a = completo.contagem[chave];
    const b = venda.contagem[chave];
    console.log(nome.padEnd(40) + (a.ok + '/' + a.total).padEnd(11)
      + (b.ok + '/' + b.total));
  }
  console.log('o documento de venda tem os mesmos ' + Object.keys(docDeVenda.paths).length
    + ' caminhos e os mesmos nomes de rota.');
  console.log('o que falta e o que diz o que a API ACEITA — e sao ' + venda.falhas.length
    + ' lacunas, agrupadas por tipo:');
  // Agrupar e o que torna a lista legivel: as 21 lacunas sao 3 defeitos
  // repetidos em varias rotas, e repetir "400 de /produtos sem exemplo de
  // resposta" quatro vezes nao ensina nada que a primeira vez nao ensinou.
  const porTipo = new Map();
  for (const f of venda.falhas) {
    const tipo = f.includes('sem exemplo de requisicao') ? 'exemplo de requisicao ausente'
      : f.includes('sem exemplo de resposta') ? 'exemplo de resposta ausente'
        : f.includes('sem tipo e sem obrigatoriedade') ? 'parametro sem tipo e sem obrigatoriedade'
          : 'codigo de erro ausente em responses';
    porTipo.set(tipo, (porTipo.get(tipo) || 0) + 1);
  }
  for (const [tipo, n] of porTipo) {
    console.log('  ' + String(n).padStart(2) + 'x  ' + tipo);
  }
  console.log('as tres primeiras linhas do exemplo sao exatamente o que o');
  console.log('documento de venda nao diz: o nome do parametro existe, e o');
  console.log('formato, a obrigatoriedade e o limite, nao.');

  // =================================================== 7. o servidor no ar
  const casa = criarRoteador(ROTAS);
  const servidor = criarServidor(banco, casa);

  await new Promise((r) => servidor.listen(0, '127.0.0.1', r));
  const base = 'http://127.0.0.1:' + servidor.address().port;
  console.log('\n--- 4. o que o servidor devolve, e se o documento cobre ---');
  console.log('servidor no ar em ' + base);
  console.log('a porta muda a cada execucao: e o listen(0) pedindo uma livre ao sistema');

  const cabecalho = { Authorization: 'Bearer ' + CHAVE_DE_EXEMPLO };

  // Os casos vem da propria definicao: o corpo do POST e o exemplo que o
  // documento publica, nao um corpo digitado aqui.
  const corpoDoDocumento = exemploCorpo(ROTAS[2].corpo);

  const casos = [
    {
      nome: 'GET da lista, com o filtro e o limite do exemplo',
      rota: ROTAS[0], metodo: 'GET', caminho: '/produtos?nm_item=mouse&limite=10',
      detalhes: '200 traz o filtro declarado: so o nome que casa',
    },
    {
      nome: 'GET de um id que existe',
      rota: ROTAS[1], metodo: 'GET', caminho: '/produtos/1',
    },
    {
      nome: 'GET de um id que nao existe',
      rota: ROTAS[1], metodo: 'GET', caminho: '/produtos/9999',
      detalhes: '404 ITEM_NAO_ENCONTRADO, declarado na rota e no documento',
    },
    {
      nome: 'GET com id que nao e inteiro',
      rota: ROTAS[1], metodo: 'GET', caminho: '/produtos/abc',
      detalhes: '400 PARAMETRO_INVALIDO: o documento ja dizia que id e inteiro',
    },
    {
      nome: 'GET com limite acima do maximo declarado',
      rota: ROTAS[0], metodo: 'GET', caminho: '/produtos?limite=500',
      detalhes: '400 PARAMETRO_INVALIDO: maximo 100 escrito no mesmo lugar da regra',
    },
    {
      nome: 'POST com a chave de exemplo e o exemplo de requisicao do documento',
      rota: ROTAS[2], metodo: 'POST', caminho: '/produtos',
      cabecalhos: cabecalho, corpo: corpoDoDocumento,
      detalhes: '201 com o id que o banco gerou',
    },
    {
      nome: 'POST sem a chave',
      rota: ROTAS[2], metodo: 'POST', caminho: '/produtos',
      corpo: corpoDoDocumento,
      detalhes: '401 SEM_CHAVE antes de tocar no banco',
    },
    {
      nome: 'POST sem o campo obrigatorio',
      rota: ROTAS[2], metodo: 'POST', caminho: '/produtos',
      cabecalhos: cabecalho, corpo: { qtd: 1 },
      detalhes: '400 CAMPO_OBRIGATORIO',
    },
    {
      nome: 'POST com nome repetido',
      rota: ROTAS[2], metodo: 'POST', caminho: '/produtos',
      cabecalhos: cabecalho, corpo: { nm_item: 'mouse', qtd: 1 },
      detalhes: '409 ITEM_DUPLICADO: ER_DUP_ENTRY traduzido para o codigo declarado',
    },
    {
      nome: 'GET num caminho que nao esta na tabela',
      rota: null, metodo: 'GET', caminho: '/nao-existe',
      detalhes: 'a resposta padrao, que o documento publica fora de paths',
    },
  ];

  let cobriuTodos = 0;
  for (const caso of casos) {
    // A linha de contexto vem ANTES da requisicao. O `console.error` do
    // servidor sai no stderr, que a pagina nao embute, e uma linha de erro
    // solta na saida real deixa o aluno sem saber o que ela estava provando.
    console.log('\n' + caso.nome);
    if (caso.corpo !== undefined) console.log('  corpo enviado: ' + JSON.stringify(caso.corpo));

    const opcoes = { method: caso.metodo };
    if (caso.cabecalhos) opcoes.headers = caso.cabecalhos;
    if (caso.corpo !== undefined) {
      opcoes.headers = Object.assign({}, caso.cabecalhos,
        { 'Content-Type': 'application/json' });
      opcoes.body = JSON.stringify(caso.corpo);
    }
    const resposta = await fetch(base + caso.caminho, opcoes);
    const texto = await resposta.text();
    const registrado = (caso.rota
      ? (doc.paths[caso.rota.caminho][caso.rota.metodo.toLowerCase()].responses[String(resposta.status)] || null)
      : doc['x-resposta-padrao']);
    const noDocumento = caso.rota ? cobre(doc, caso.rota, resposta.status)
      : resposta.status === doc['x-resposta-padrao'].status;
    if (noDocumento) cobriuTodos += 1;

    console.log('  resposta    : ' + resposta.status + ' ' + texto);
    // O que o documento publica para aquele status: o codigo, quando existe, e
    // o exemplo do corpo. Uma resposta de sucesso nao tem codigo de erro, e a
    // linha mostra o exemplo — que e o que o cliente copia para provar.
    // `x-resposta-padrao` tem um `codigo` solto e nao a lista `x-codigos`, e e
    // por isso que os dois formatos sao lidos aqui em vez de num so lugar.
    const codigos = registrado && (registrado['x-codigos'] || [registrado.codigo])
      .filter(Boolean);
    const exemplo = registrado && registrado.content
      && registrado.content['application/json'].example;
    console.log('  documento   : ' + (noDocumento
      ? 'publica o status ' + resposta.status
        + (codigos.length
          ? ' (' + codigos.join(', ') + ')'
          : ' com o exemplo ' + JSON.stringify(exemplo))
      : 'NAO publica o status ' + resposta.status + ' — divergencia'));
    if (caso.detalhes) console.log('  ' + caso.detalhes);
  }
  console.log('\n' + cobriuTodos + ' de ' + casos.length
    + ' respostas do servidor estao cobertas pelo documento.');

  // O exemplo de requisicao do documento e o corpo que foi enviado de verdade:
  console.log('corpo do exemplo no documento: ' + JSON.stringify(corpoDoDocumento));
  console.log('foi esse corpo que o POST recebeu, sem reescrita — o exemplo nao e');
  console.log('uma foto antiga: ele sai dos campos declarados em `corpo.campos`.');

  // =================================================== 8. o exemplo de resposta
  // O exemplo de resposta do documento precisa ter as mesmas chaves do que o
  // servidor devolveu. E a conferencia que pega o "quase igual".
  const [gravado] = await banco.execute(
    'SELECT id, nm_item, qtd FROM tb_d14a1_produto WHERE nm_item = ?', ['monitor']);
  const exemplo201 = doc.paths['/produtos'].post.responses['201'].content['application/json'].example;
  const clavesExemplo = Object.keys(exemplo201).sort().join(',');
  const chavesReais = Object.keys(gravado[0]).sort().join(',');
  console.log('\n--- 5. o exemplo de resposta casa com a resposta de verdade? ---');
  console.log('exemplo no documento: ' + JSON.stringify(exemplo201));
  console.log('linha no banco      : ' + JSON.stringify(gravado[0]));
  console.log('chaves: ' + (clavesExemplo === chavesReais ? 'iguais (' + chavesReais + ')' : 'DIFERENTES'));

  // O banco ficou como o documento diz: duas linhas do seed e o produto novo.
  const [total] = await banco.query('SELECT COUNT(*) AS total FROM tb_d14a1_produto');
  console.log('linhas na tabela depois de todos os casos: ' + total[0].total);

  await new Promise((r) => servidor.close(r));
  console.log('\nservidor encerrado com close().');

  await banco.end();
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1

--- 1. a tabela das rotas, a mesma que virou o documento ---
metodo  caminho             o que faz                o que devolve             status de erro
------  ------------------  -----------------------  ------------------------  ----------------------------------
GET     /produtos           lista os produtos        200 lista de produtos     400 PARAMETRO_INVALIDO
GET     /produtos/{id}      devolve um produto pelo  200 o produto pedido      400 PARAMETRO_INVALIDO; 404
                            id                                                 ITEM_NAO_ENCONTRADO
POST    /produtos           cadastra um produto      201 o produto             400 CAMPO_OBRIGATORIO; 400
                                                     cadastrado, com o id que  PARAMETRO_INVALIDO; 401 SEM_CHAVE;
                                                     o banco gerou             409 ITEM_DUPLICADO

o que devolve vem de `respostas`; o status do erro vem de `erros`,
nunca de uma frase escrita dentro do handler.
essa e a tabela que a pessoa procura antes de abrir o JSON — e ela
esta no `README.md`, gerada da mesma definicao que gerou o documento.

--- 2. a estrutura do documento OpenAPI ---
openapi      : 3.0.3
info.title   : API de produtos do material
info.version : 1.0.0
paths        : 2 caminhos, 3 operacoes
x-como-rodar : 4 passos, chave de exemplo declarada
x-resposta-padrao: ROTA_DESCONHECIDA (status 404)

as chaves da operacao POST /produtos, na ordem em que o Swagger UI le:
  summary
  operationId
  description
  requestBody {
    required
    content {
      application/json {
      }
    }
  }
  responses {
    201 {
      description
      x-codigos[]
      content {
      }
    }
    400 {
      description
      x-codigos[] (2)
      content {
      }
    }
    401 {
      description
      x-codigos[] (1)
      content {
      }
    }
    409 {
      description
      x-codigos[] (1)
      content {
      }
    }
  }
  security[] (1)

"parameters" da rota GET /produtos/{id}, declarados campo a campo:
[
  {
    "name": "id",
    "in": "path",
    "required": true,
    "description": "identificador do produto, o que a tabela tb_d14a1_produto gravou",
    "schema": {
      "type": "integer"
    },
    "example": "1"
  }
]

--- 2b. a outra metade: instalar, rodar e a chave de exemplo ---
exige node: 18 ou superior
exige banco: qualquer servidor MySQL ou MariaDB
passos:
  npm install
  copiar env.exemplo para .env e preencher com a credencial local
  npm start
  npm test
chave de exemplo no documento: Authorization: Bearer exemplo-do-material
formato declarado em components.securitySchemes: http / bearer
a chave de exemplo e um valor ficticio: nenhum segredo e
versionado, so o formato e o nome do cabecalho.

--- 3. a mesma definicao, dois documentos ---
item                                    completo   de venda
documentar rota                         3/3        3/3
documentar parametro                    3/3        0/3
exemplo de requisicao                   1/1        0/1
exemplo de resposta                     10/10      0/10
codigo de erro                          7/7        0/7
resposta padrao (caminho desconhecido)  1/1        1/1
instalacao, como rodar e chave          1/1        1/1
o documento de venda tem os mesmos 2 caminhos e os mesmos nomes de rota.
o que falta e o que diz o que a API ACEITA — e sao 21 lacunas, agrupadas por tipo:
   3x  parametro sem tipo e sem obrigatoriedade
  10x  exemplo de resposta ausente
   7x  codigo de erro ausente em responses
   1x  exemplo de requisicao ausente
as tres primeiras linhas do exemplo sao exatamente o que o
documento de venda nao diz: o nome do parametro existe, e o
formato, a obrigatoriedade e o limite, nao.

--- 4. o que o servidor devolve, e se o documento cobre ---
servidor no ar em http://127.0.0.1:33379
a porta muda a cada execucao: e o listen(0) pedindo uma livre ao sistema

GET da lista, com o filtro e o limite do exemplo
  resposta    : 200 {"produtos":[{"id":1,"nm_item":"mouse","qtd":5}]}
  documento   : publica o status 200 com o exemplo {"produtos":[{"id":1,"nm_item":"mouse","qtd":5}]}
  200 traz o filtro declarado: so o nome que casa

GET de um id que existe
  resposta    : 200 {"id":1,"nm_item":"mouse","qtd":5}
  documento   : publica o status 200 com o exemplo {"id":1,"nm_item":"mouse","qtd":5}

GET de um id que nao existe
  resposta    : 404 {"erro":"ITEM_NAO_ENCONTRADO","mensagem":"nao existe produto com esse id"}
  documento   : publica o status 404 (ITEM_NAO_ENCONTRADO)
  404 ITEM_NAO_ENCONTRADO, declarado na rota e no documento

GET com id que nao e inteiro
  resposta    : 400 {"erro":"PARAMETRO_INVALIDO","mensagem":"id precisa ser numero inteiro"}
  documento   : publica o status 400 (PARAMETRO_INVALIDO)
  400 PARAMETRO_INVALIDO: o documento ja dizia que id e inteiro

GET com limite acima do maximo declarado
  resposta    : 400 {"erro":"PARAMETRO_INVALIDO","mensagem":"limite precisa ser menor ou igual a 100"}
  documento   : publica o status 400 (PARAMETRO_INVALIDO)
  400 PARAMETRO_INVALIDO: maximo 100 escrito no mesmo lugar da regra

POST com a chave de exemplo e o exemplo de requisicao do documento
  corpo enviado: {"nm_item":"monitor","qtd":5}
  resposta    : 201 {"id":3,"nm_item":"monitor","qtd":5}
  documento   : publica o status 201 com o exemplo {"id":3,"nm_item":"monitor","qtd":5}
  201 com o id que o banco gerou

POST sem a chave
  corpo enviado: {"nm_item":"monitor","qtd":5}
  resposta    : 401 {"erro":"SEM_CHAVE","mensagem":"falta o cabecalho Authorization"}
  documento   : publica o status 401 (SEM_CHAVE)
  401 SEM_CHAVE antes de tocar no banco

POST sem o campo obrigatorio
  corpo enviado: {"qtd":1}
  resposta    : 400 {"erro":"CAMPO_OBRIGATORIO","mensagem":"nm_item e obrigatorio"}
  documento   : publica o status 400 (CAMPO_OBRIGATORIO, PARAMETRO_INVALIDO)
  400 CAMPO_OBRIGATORIO

POST com nome repetido
  corpo enviado: {"nm_item":"mouse","qtd":1}
  resposta    : 409 {"erro":"ITEM_DUPLICADO","mensagem":"esse nome ja existe"}
  documento   : publica o status 409 (ITEM_DUPLICADO)
  409 ITEM_DUPLICADO: ER_DUP_ENTRY traduzido para o codigo declarado

GET num caminho que nao esta na tabela
  resposta    : 404 {"erro":"ROTA_DESCONHECIDA","mensagem":"o caminho nao existe nesta API"}
  documento   : publica o status 404 (ROTA_DESCONHECIDA)
  a resposta padrao, que o documento publica fora de paths

10 de 10 respostas do servidor estao cobertas pelo documento.
corpo do exemplo no documento: {"nm_item":"monitor","qtd":5}
foi esse corpo que o POST recebeu, sem reescrita — o exemplo nao e
uma foto antiga: ele sai dos campos declarados em `corpo.campos`.

--- 5. o exemplo de resposta casa com a resposta de verdade? ---
exemplo no documento: {"id":3,"nm_item":"monitor","qtd":5}
linha no banco      : {"id":3,"nm_item":"monitor","qtd":5}
chaves: iguais (id,nm_item,qtd)
linhas na tabela depois de todos os casos: 3

servidor encerrado com close().
Aula 2

Entregar o projeto completo

O que acompanha uma entrega

O código é a parte fácil. O que separa o projeto final entregue de um repositório abandonado é um conjunto de arquivos que ninguém precisa te perguntar, e cada um deles responde a uma pergunta específica:

ArquivoPergunta que respondeVai para o git
README.mdo que é isto, e como eu subosim
env.exemploque variáveis eu preciso preenchersim, só os nomes
.gitignoreo que não entrasim
migrations/como nasce o esquemasim
test/como eu sei que funcionasim
package.jsoncom que comando cada coisa rodasim
.envqual é a credencial desta máquinanunca

A última linha é a única que não vai para o git, e é a única que alguém vai procurar quando o projeto não sobe na máquina nova. O .env é o arquivo que a pessoa cria, e o env.exemplo é o que diz o que criar.

O README.md é configuração, não cortesia

O README não é texto de apresentação: é o que faz o projeto rodar na máquina de outra pessoa. As seções que não podem faltar, cada uma tied a uma pergunta:

  • o que é — três frases, sem adjetivo;
  • instalação — versão do Node, o que precisa existir;
  • como rodar — a sequência de comandos, na ordem, em bloco de código copiável;
  • variáveis de ambiente — a tabela nome/para que serve, e onde o valor fica;
  • migrações — como criar o esquema, e como desfazer;
  • testes — o comando, e o que o teste garante.

O defeito mais comum não é a seção faltando. É a seção certa com o conteúdo errado: um "como rodar" que diz node server.js quando o entry point é src/server.js, ou uma versão de banco escrita à mão que só vale na máquina de quem escreveu. A versão do banco se captura com SELECT VERSION() e o README diz "qualquer servidor MySQL ou MariaDB" — que é o que o código de fato exige.

O README também carrega uma peça que o OpenAPI não tem onde: a tabela de rotas. Método, caminho, o que faz, o que devolve, status de erro. Uma linha por rota, e é a primeira coisa que a pessoa procura antes de abrir qualquer JSON.

env.exemplo é o .env sem os valores

O par é simples e a distinção é o que importa:

# env.exemplo — vai no git
DB_HOST=
DB_PORT=
DB_USER=
DB_PASS=
DB_NAME=
NODE_ENV=development

O nome vai no git. O valor não, e no modelo o valor é vazio — um env.exemplo com a senha preenchida é o .env com outro nome, e o vazamento é o mesmo. NODE_ENV tem valor porque não é segredo e tem um padrão útil; é a única exceção, e ela é legítima.

Um detalhe de nome que cria confusão: env.exemplo (sem ponto) e .env.example (com ponto) são usados por projetos diferentes. O . do começo não é cosmetics — é ele que faz o git tratar o arquivo como oculto e o .gitignore casar com .env.*.

.gitignore é o arquivo que ninguém abre depois do primeiro commit

Quatro linhas resolvem a maior parte:

node_modules/
.env
.env.*
!env.exemplo
  • node_modules/ é reconstruível com npm ci, e é por isso que nunca é versionado;
  • .env recusa o arquivo de credencial;
  • .env.* recusa .env.producao, que é o jeito mais comum de vazar: o .env está protegido e o .env.producao, que tem a senha de verdade, não;
  • !env.exemplo volta a aceitar o modelo. O ! é negação e vem depois — regra mais abaixo vence a de cima.

Entra também a pasta do banco local (dados/, o dump de desenvolvimento), pelo mesmo motivo do node_modules: é reconstruível, e um dump costuma ter dado de outra pessoa dentro.

Os dois comandos que provam, e a diferença entre eles

.gitignore é uma regra que o git aplica. Para conferir, são dois comandos que medem coisas diferentes:

git check-ignore -v .env     # o que o .gitignore RECUSA — a regra, agora
git ls-files .env            # o que o git REALMENTE versiona — o que já foi

git check-ignore -v sai com 0 quando a regra existe e 1 quando não existe, e o -v imprime qual linha do .gitignore casou. git ls-files lista o índice: o que está versionado agora, independentemente do que o .gitignore diz hoje.

A diferença entre os dois é toda a aula. Se check-ignore responde e ls-files .env mostra o arquivo, o .env já foi comitado em algum momento. Se check-ignore não responde, o arquivo está pronto para o próximo git add ..

O erro clássico: entregar com o .env no git

git add . pega o .env inteiro quando o .gitignore não recusa, e a linha do arquivo parece uma linha de configuração e parece inofensiva. Por isso a regra não é "escreva a senha com cuidado" — é o arquivo não pode estar no caminho do git.

Quando isso acontece, a sequência que todo mundo tenta é apagar o arquivo e commitar de novo. Não resolve. O exemplo do dia faz exatamente isso numa entrega de verdade e mede o resultado:

1) rm .env && git add . && git commit -m "remove o .env"
   git ls-files .env agora: (nenhuma linha) — saiu do indice

O índice ficou limpo. E o valor continua no commit antigo:

git log --all --full-history -- .env  ->  8 linha(s) — o arquivo EXISTE no historico

A correção que resolve, nesta ordem:

  1. trocar a senha do banco — é o único passo que desfaz o vazamento;
  2. reescrever o histórico (git filter-repo ou BFG) e forçar o push;
  3. avisar quem já clonou — quem tem a cópia tem o valor, e forçar o push não apaga a cópia do clone.

Apagar o arquivo resolve o arquivo. Trocar a senha resolve o valor. São coisas diferentes, e só a segunda desfaz o vazamento.

.gitignore só vale para arquivo ainda não commitado. Regra de .gitignore nunca remove do histórico: o histórico é uma sequência de commits, e cada commit é o que era quando foi feito.

organização de arquivos, padrão de nome e tamanho de arquivo

A estrutura final que aguenta um projeto que cresce:

projeto/
  README.md
  package.json
  env.exemplo
  .gitignore
  src/          servidor, rotas, repositorio
  test/         um arquivo .test.js por caso
  migrations/   001_..., 002_..., com -- up e -- down

Três regras de nome que economizam a próxima discussion:

  • padrão de nome descritivo — produtoRepository.js, não auxiliar.js nem utils.js. Nome genérico é dívida: quando seis arquivos se chamam utils.js, ninguém sabe qual editar, e o git junta os dois no mesmo diff.
  • tamanho de arquivo com teto — a partir de ~300 linhas o arquivo precisa virar outro arquivo. Não é regra estética: é que ninguém revisa um arquivo que não cabe em duas telas, e código que ninguém revisa é código que ninguém corrige.
  • migração numerada — 001_criar_tb_produto.sql, 002_criar_tb_log.sql. O prefixo de três dígitos é a ordem de aplicação, e sem ele a ordem fica a cargo do filesystem, que não é nem alfabética nem a de criação.

O package.json fecha a lista, e o que importa são os scripts:

{
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js",
    "test": "node --test",
    "migrate": "node src/migrate.js"
  }
}

start roda o servidor, dev recarrega a cada mudança, test roda o teste e migrate aplica o esquema. São os verbos que quem clona o repositório vai procurar — e um teste sem script é um teste que ninguém roda.

código legível, comentário que explica o porquê e remover código morto

comentário que explica o porquê é o que sobrevive à edição. O // preenche o campo ao lado de campo = valor não informa nada e envelhece errado na terceira mudança; o // o limite vai no VALUES como valor porque o MySQL nao aceita ? na posicao de LIMIT informa e continua verdadeiro.

O comentário de topo do arquivo é o mais valuable dele: é a resposta de "o que este arquivo faz" sem abrir o editor. E é o que permite a quem assume a manutenção saber onde mexer.

remover código morto e dependência desnecessária são o mesmo erro visto de dois lados. Uma função que ninguém chama e uma biblioteca no package.json que ninguém require são a mesma coisa: quem instala instala sem precisar, e quem lê lê sem entender por que aquilo está ali. A verificação é mecânica e o exemplo a faz: package.json tem uma dependência, src/ não a usa, e a entrega reprova.

A lista de verificação é ela mesma um pedaço de código. Cada item é uma função que recebe { raiz, git } e devolve { ok, motivo } — e o motivo é o que separa uma verificação útil de um "está tudo certo" que não diz nada. O exemplo do dia passa a lista inteira em duas árvores com o mesmo código e imprime o resultado item por item: o único item que reprova nas duas é o do .gitignore, e a única diferença entre as árvores é uma linha dele.

Verificação que só diz SIM ou NÃO também não serve: a pessoa que recebe a falha precisa do motivo, do arquivo e do nome do que faltou. Por isso o item do segredo levanta a linha e o nome da variável, e nunca o valor: uma verificação que imprime a senha para provar que ela está no repositório é uma verificação que publica a senha no relatório.

Exemplo

'use strict';

// Exemplo da aula 2 do dia 14: a entrega do projeto final.
//
// O que a aula ensina, em uma frase: o codigo e a parte facil da entrega. O que
// separa o projeto final entregue de um repositorio abandonado e um conjunto de
// arquivos que ninguem consegue rodar sem te perguntar.
//
// Por isso este exemplo NAO e o projeto final: e um VERIFICADOR dele. Ele cria
// uma arvore de entrega de verdade em disco temporario, com a `estrutura final`
// completa — README, `env.exemplo`, `.gitignore`, migracoes, testes e
// `package.json` — e depois passa por cima dela item por item, com a
// `organizacao de arquivos`, o `padrao de nome` e o `tamanho de arquivo`
// medidos, como quem revisa a entrega de outra pessoa. Cada item sai com SIM ou
// NAO e o motivo.
//
// E o git de verdade: `git init`, `git add`, `git commit`. O `.env` esta no
// `.gitignore` de um jeito e no do outro, e a diferenca entre as duas entregas
// aparece em `git ls-files`, que e o unico comando que diz o que ENTROU no
// repositorio. E o exemplo termina fazendo a pergunta que precisa ser feita: o
// que acontece quando o `.env` ja foi comitado uma vez.
//
// Nenhum segredo e escrito aqui. O arquivo `.env` que o exemplo cria tem
// valores ficticios, e o valor de `DB_PASS` nunca e impresso nem conferido: a
// verificacao levanta o NOME da variavel e a linha, nunca o conteudo.

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createConnection } = require('mysql2/promise');

// ================================================ 1. o checklist, como dado
//
// Cada item e uma pergunta com resposta. `id` e a chave — e o que vai para a
// tabela do MySQL e o que permite comparar as duas entregas item a item.
// `resposta` aponta para a funcao de verificacao, e ela devolve `{ ok, motivo }`:
// o `motivo` e o texto que entra na pagina quando a resposta e NAO, porque um
// "NAO" sem motivo nao ajuda quem esta corrigindo.
const CHECKLIST = [
  {
    id: 'readme',
    item: 'README.md tem o que e, instalar, rodar e variaveis',
    resposta: conferirReadme,
  },
  {
    id: 'gitignore-env',
    item: '.env esta no .gitignore',
    resposta: conferirGitignore,
  },
  {
    id: 'env-example',
    item: 'env.exemplo existe, com os NOMES e o valor vazio',
    resposta: conferirEnvExemplo,
  },
  {
    id: 'migracao',
    item: 'migracao com ordem, up e down, e a ordem no README',
    resposta: conferirMigracoes,
  },
  {
    id: 'testes',
    item: 'os testes existem e o package.json tem o script',
    resposta: conferirTestes,
  },
  {
    id: 'scripts',
    item: 'o package.json tem scripts de start, dev e test',
    resposta: conferirScripts,
  },
  {
    id: 'nomes',
    item: 'nenhum nome de simbolo do sistema, nenhum arquivo gigante',
    resposta: conferirNomes,
  },
  {
    id: 'morto',
    item: 'nenhum codigo morto, nenhuma dependencia desnecessaria',
    resposta: conferirCodigoMorto,
  },
  {
    id: 'legivel',
    item: 'todo arquivo tem comentario que explica o que faz',
    resposta: conferirLegibilidade,
  },
  {
    id: 'segredo',
    item: 'nenhum segredo em nenhum arquivo versionado',
    resposta: conferirSegredos,
  },
];

// Os nomes das variaveis de ambiente do projeto. E esta lista que o
// `.env.example` tem que repetir inteira, e nao a lista do `.env`: quem clona o
// repositorio nao tem `.env` nenhum.
const VARIAVEIS = ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASS', 'DB_NAME', 'NODE_ENV'];

// As secoes que o `README.md` precisa ter. Cada uma responde a uma pergunta que
// a pessoa nova faz, e uma secao faltando e exatamente a pergunta sem resposta.
// `no` e o texto aceito, sem acento: e assim que a secao esta escrita no
// README de verdade, e um README correto nunca e reprovado por causa do acento.
const SECOES_DO_README = [
  { titulo: 'O que e', no: 'o que e', pergunta: 'o que este projeto faz' },
  { titulo: 'Instalacao', no: 'instalacao', pergunta: 'o que precisa existir na maquina' },
  { titulo: 'Como rodar', no: 'como rodar', pergunta: 'a sequencia de comandos' },
  { titulo: 'Variaveis de ambiente', no: 'variaveis de ambiente', pergunta: 'o que preencher no .env' },
  { titulo: 'Migracoes', no: 'migracoes', pergunta: 'como criar o esquema' },
  { titulo: 'Testes', no: 'testes', pergunta: 'como conferir que funciona' },
];

// `semAcento(texto)` devolve o texto sem os acentos combinantes e em minuscula.
// O `NFD` separa a letra do acento em dois caracteres, e e o que permite
// comparar "Instalacao" com "instalação" sem escrever a lista de acentos.
function semAcento(texto) {
  return texto.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
}

// ============================================== 2. as funcoes de verificacao
//
// Cada `conferir*` recebe `{ raiz, git }` — um objeto, nao dois argumentos — e
// devolve `{ ok, motivo }`. O objeto e o que permite acrescentar um verificador
// novo sem mexer na chamada: o `CHECKLIST` chama sempre com a mesma forma.
// Nenhuma delas imprime: a impressao e uma vez, no fim, para que o resultado da
// entrega inteira saia como uma lista e nao como intercalar com o arquivo.

// `conferirReadme(raiz)` procura o arquivo e as secoes. A secao e procurada
// pelo titulo sem acento e em minuscula, porque e assim que a pessoa escreve e
// e assim que a busca na pagina funciona.
function conferirReadme({ raiz }) {
  const arquivo = path.join(raiz, 'README.md');
  if (!fs.existsSync(arquivo)) {
    return { ok: false, motivo: 'README.md nao existe no projeto' };
  }
  const texto = semAcento(fs.readFileSync(arquivo, 'utf8'));
  const faltando = SECOES_DO_README.filter((s) => !texto.includes(s.no));
  if (faltando.length > 0) {
    return {
      ok: false,
      motivo: 'faltam as secoes: ' + faltando.map((s) => s.titulo).join(', '),
    };
  }
  return {
    ok: true,
    motivo: SECOES_DO_README.length + ' secoes presentes',
  };
}

// `conferirGitignore(raiz)` le as linhas do `.gitignore` e pergunta ao proprio
// git, com `check-ignore`, se o arquivo e recusado. A pergunta ao git e o que
// torna a verificacao real: um `.gitignore` com a regra escrita mas depois de
// uma `!env.exemplo` mal colocada pode nao valer.
function conferirGitignore({ raiz, git }) {
  const arquivo = path.join(raiz, '.gitignore');
  if (!fs.existsSync(arquivo)) {
    return { ok: false, motivo: '.gitignore nao existe: o .env entra no proximo git add' };
  }
  const r = git(['check-ignore', '-v', '.env']);
  if (!r.ok) {
    return { ok: false, motivo: 'o git NAO recusa o .env: nada no .gitignore casa com ele' };
  }
  const linhas = fs.readFileSync(arquivo, 'utf8').split('\n')
    .map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
  return {
    ok: true,
    motivo: 'git recusa o .env (' + r.linhas[0] + '), e sao ' + linhas.length
      + ' regra(s) no arquivo',
  };
}

// `conferirEnvExemplo(raiz)` e o par do `.gitignore`: o modelo vai para o git,
// com os NOMES e o valor vazio. Um modelo com valor preenchido e o `.env` com
// outro nome — o segredo entra igual.
function conferirEnvExemplo({ raiz }) {
  // Os dois nomes convivem: `env.exemplo` e o deste material, `.env.example` e
  // o do `dotenv` e da maioria dos projetos com framework. O que importa e que
  // exista UM dos dois, com os nomes das variaveis e o valor vazio.
  const candidatos = ['env.exemplo', '.env.example']
    .map((n) => path.join(raiz, n))
    .filter((c) => fs.existsSync(c));
  if (candidatos.length === 0) {
    return {
      ok: false,
      motivo: 'nem env.exemplo nem .env.example existe: '
        + 'quem clona nao sabe que variaveis preencher',
    };
  }
  const arquivo = candidatos[0];
  const nomes = nomesDoEnv(arquivo);
  const faltando = VARIAVEIS.filter((v) => !nomes.some((n) => n.nome === v));
  if (faltando.length > 0) {
    return { ok: false, motivo: 'faltam os nomes: ' + faltando.join(', ') };
  }
  const comValor = nomes.filter((n) => n.preenchida && n.nome !== 'NODE_ENV');
  if (comValor.length > 0) {
    // O nome da variavel e levantado; o valor nunca entra na mensagem.
    return {
      ok: false,
      motivo: 'valor preenchido em ' + comValor.map((n) => n.nome).join(', ')
        + ' — no modelo o valor e vazio',
    };
  }
  return {
    ok: true,
    motivo: path.basename(arquivo) + ' com ' + nomes.length
      + ' nomes e valor vazio',
  };
}

// `nomesDoEnv(caminho)` le um arquivo `chave=valor` e devolve so o nome e se
// o valor esta preenchido. O valor e lido e descartado ali mesmo.
function nomesDoEnv(caminho) {
  const saida = [];
  for (const linha of fs.readFileSync(caminho, 'utf8').split('\n')) {
    const limpa = linha.trim();
    if (!limpa || limpa.startsWith('#')) continue;
    const corte = limpa.indexOf('=');
    if (corte === -1) continue;
    saida.push({
      nome: limpa.slice(0, corte).trim(),
      preenchida: limpa.slice(corte + 1).trim() !== '',
    });
  }
  return saida;
}

// `conferirMigracoes(raiz)` olha a pasta `migrations`. Uma entrega sem migracao
// cria as tabelas no proprio codigo, e ai ninguem consegue subir um banco novo
// sem ler o servidor inteiro. A ordem e o prefixo numerico do nome do arquivo.
function conferirMigracoes({ raiz }) {
  const pasta = path.join(raiz, 'migrations');
  if (!fs.existsSync(pasta)) {
    return { ok: false, motivo: 'pasta migrations nao existe' };
  }
  const arquivos = fs.readdirSync(pasta).filter((f) => f.endsWith('.sql')).sort();
  if (arquivos.length === 0) {
    return { ok: false, motivo: 'nenhum arquivo .sql em migrations' };
  }
  const semUpDown = arquivos.filter((f) => {
    const texto = fs.readFileSync(path.join(pasta, f), 'utf8').toLowerCase();
    return !texto.includes('-- up') || !texto.includes('-- down');
  });
  if (semUpDown.length > 0) {
    return { ok: false, motivo: 'sem `-- up` e sem `-- down`: ' + semUpDown.join(', ') };
  }
  const numerados = arquivos.every((f) => /^\d{3}_/.test(f));
  const ordem = arquivos.map((f) => f.slice(0, 3)).join(' < ');
  if (!numerados) {
    return {
      ok: false,
      motivo: 'nome sem o prefixo de 3 digitos: a ordem de aplicacao fica a cargo do filesystem',
    };
  }
  // A ordem so serve se o README disser qual e. Um README sem a secao de
  // migracoes deixa quem clona sem saber se `001` vem antes de `002` — e ele
  // vai, so que por sorte.
  const readme = path.join(raiz, 'README.md');
  const leiavel = fs.existsSync(readme)
    && semAcento(fs.readFileSync(readme, 'utf8')).includes('migracoes');
  if (!leiavel) {
    return {
      ok: false,
      motivo: arquivos.length + ' migracao(oes) em ordem ' + ordem
        + ', mas o README nao tem a secao que diz qual aplicar primeiro',
    };
  }
  return {
    ok: true,
    motivo: arquivos.length + ' migracao(oes) em ordem ' + ordem
      + ', e o README diz a ordem',
  };
}

// `conferirTestes(raiz)` procura a pasta `test` com arquivos `.test.js`, e
// confere se o `package.json` tem o script `test`. Teste sem script e teste
// que ninguem roda.
function conferirTestes({ raiz }) {
  const pasta = path.join(raiz, 'test');
  if (!fs.existsSync(pasta)) {
    return { ok: false, motivo: 'pasta test nao existe' };
  }
  const arquivos = fs.readdirSync(pasta).filter((f) => f.endsWith('.test.js'));
  if (arquivos.length === 0) {
    return { ok: false, motivo: 'nenhum arquivo .test.js em test' };
  }
  const pacote = lerPacote(raiz);
  const script = pacote.scripts && pacote.scripts.test;
  if (!script) {
    return {
      ok: false,
      motivo: arquivos.length + ' teste(s) existem e o package.json nao tem o script "test"',
    };
  }
  return { ok: true, motivo: arquivos.length + ' teste(s), script: "' + script + '"' };
}

// `conferirScripts(raiz)` le o `package.json` e pergunta pelos tres scripts que
// toda entrega tem. `start` roda o servidor, `dev` recarrega, `test` roda o
// teste: sao os tres verbos que quem clona o repositorio vai procurar.
function conferirScripts({ raiz }) {
  const pacote = lerPacote(raiz);
  if (pacote.erro) return { ok: false, motivo: pacote.erro };
  const scripts = pacote.scripts || {};
  const faltando = ['start', 'dev', 'test'].filter((s) => !scripts[s]);
  if (faltando.length > 0) {
    return { ok: false, motivo: 'faltam os scripts: ' + faltando.join(', ') };
  }
  return {
    ok: true,
    motivo: Object.keys(scripts).length + ' scripts: '
      + Object.keys(scripts).sort().join(', '),
  };
}

// `lerPacote(raiz)` devolve o `package.json` ja convertido, ou um objeto com
// `erro`. O `JSON.parse` e protegido porque um `package.json` com virgula e o
// jeito mais comum de entrega quebrada — e a falha precisa aparecer como
// "package.json invalido", nao como uma exception no meio da verificacao.
function lerPacote(raiz) {
  const arquivo = path.join(raiz, 'package.json');
  if (!fs.existsSync(arquivo)) {
    return { erro: 'package.json nao existe' };
  }
  try {
    return JSON.parse(fs.readFileSync(arquivo, 'utf8'));
  } catch (erro) {
    return { erro: 'package.json invalido: ' + erro.message };
  }
}

// Os nomes que denunciam entrega escrita com pressa. O primeiro grupo e nome
// de simbolo do sistema, que quebra ao rodar em outro sistema; o segundo e
// nome generico, que nao diz nada sobre o arquivo.
const NOMES_PROIBIDOS = [
  'auxiliar', 'util', 'utils', 'helper', 'helpers', 'misc',
  'teste', 'test', 'novo', 'novo1', 'copia', 'backup', 'temp', 'tmp',
];
const EXTENSAO_ESPERADA = { '.js': '.js', '.json': '.json', '.md': '.md', '.sql': '.sql' };

// `conferirNomes(raiz)` percorre `src/` e mede o tamanho e o nome de cada
// arquivo. `tamanho de arquivo` e `padrao de nome` sao as duas coisas que a
// pessoa procura quando assume que vai mexer em algum coisa.
function conferirNomes({ raiz }) {
  const pasta = path.join(raiz, 'src');
  if (!fs.existsSync(pasta)) {
    return { ok: false, motivo: 'pasta src nao existe' };
  }
  const arquivos = listarArquivos(pasta);
  if (arquivos.length === 0) {
    return { ok: false, motivo: 'nenhum arquivo em src' };
  }
  const proibidos = arquivos.filter((f) => NOMES_PROIBIDOS
    .includes(path.basename(f).replace(/\.[^.]+$/, '').toLowerCase()));
  if (proibidos.length > 0) {
    return {
      ok: false,
      motivo: 'nome generico ou de simbolo do sistema: '
        + proibidos.map((f) => path.basename(f)).join(', '),
    };
  }
  const maior = arquivos.reduce((a, b) => (linhasDe(b) > linhasDe(a) ? b : a));
  const limite = 300;
  if (linhasDe(maior) > limite) {
    return {
      ok: false,
      motivo: path.basename(maior) + ' tem ' + linhasDe(maior)
        + ' linhas, acima do limite de ' + limite,
    };
  }
  return {
    ok: true,
    motivo: arquivos.length + ' arquivo(s) em src, o maior com ' + linhasDe(maior) + ' linhas',
  };
}

// `linhasDe(arquivo)` conta as linhas sem commented. E o que a pessoa ve no
// editor quando abre o arquivo: o numero da ultima linha.
function linhasDe(arquivo) {
  return fs.readFileSync(arquivo, 'utf8').split('\n').length;
}

// `listarArquivos(pasta)` percorre a pasta e devolve o caminho de cada arquivo
// `.js`, `.json`, `.md` ou `.sql`, sem descer em `node_modules`.
function listarArquivos(pasta, achados = []) {
  for (const entrada of fs.readdirSync(pasta, { withFileTypes: true })) {
    if (entrada.name === 'node_modules' || entrada.name.startsWith('.')) continue;
    const completo = path.join(pasta, entrada.name);
    if (entrada.isDirectory()) {
      listarArquivos(completo, achados);
    } else if (EXTENSAO_ESPERADA[path.extname(entrada.name)]) {
      achados.push(completo);
    }
  }
  return achados;
}

// `conferirCodigoMorto({ raiz })` procura as duas faces do mesmo erro: o
// `remover codigo morto` (a funcao que ninguem chama) e a `dependencia
// desnecessaria` (a biblioteca do `package.json` que ninguem `require`).
//
// A segunda face e a que se mede em numero, e e por isso que ela basta: um
// `require` varrido em `src/` diz exatamente quais pacotes estao em uso, e o
// `package.json` diz quais foram declarados. A diferenca entre as duas listas
// e a `dependencia desnecessaria` — e e o NOME dela que a mensagem levanta,
// porque e o nome que a pessoa precisa corrigir no arquivo.
function conferirCodigoMorto({ raiz }) {
  const pacote = lerPacote(raiz);
  const usados = new Set();
  for (const arquivo of listarArquivos(path.join(raiz, 'src'))) {
    const texto = fs.readFileSync(arquivo, 'utf8');
    for (const achado of texto.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) {
      // `require('mysql2/promise')` e o mesmo pacote que `require('mysql2')`:
      // o caminho depois da barra e o ponto de entrada do pacote. Comparar a
      // string inteira acusaria dependencia orfa num projeto correto.
      usados.add(achado[1].startsWith('@')
        ? achado[1].split('/').slice(0, 2).join('/')
        : achado[1].split('/')[0]);
    }
  }
  const externas = Object.keys(pacote.dependencies || {});
  const orfas = externas.filter((d) => !usados.has(d));
  if (orfas.length > 0) {
    return {
      ok: false,
      motivo: 'dependencia nao usada em src: ' + orfas.join(', ')
        + ' — quem instala, instala sem precisar',
    };
  }
  return { ok: true, motivo: externas.length + ' dependencia(s), todas usadas no src' };
}

// `conferirLegibilidade(raiz)` mede duas coisas: se todo arquivo tem o
// comentario no topo que explica o que ele faz, e o tamanho. `codigo legivel` e
// `comentario que explica o porquê` sao o mesmo item visto de dois lados.
function conferirLegibilidade({ raiz }) {
  const arquivos = listarArquivos(path.join(raiz, 'src'));
  const semCabecalho = arquivos.filter((f) => {
    const texto = fs.readFileSync(f, 'utf8');
    // O comentario do topo e o que vem antes do primeiro `require`: e a
    // resposta da pergunta "o que este arquivo faz", sem abrir o editor.
    const antes = texto.split('require(')[0];
    return antes.split('\n').filter((l) => l.trim()).length < 4;
  });
  if (semCabecalho.length > 0) {
    return {
      ok: false,
      motivo: 'sem comentario no topo: ' + semCabecalho.map((f) => path.basename(f)).join(', '),
    };
  }
  return {
    ok: true,
    motivo: arquivos.length + ' arquivo(s) com comentario no topo explicando o que faz',
  };
}

// `conferirSegredos(raiz)` e o item que reprova a entrega inteira. Ele percorre
// TODO o repositorio — nao so `src` — e procura o padrao de credencial. O que
// ele levanta e a LINHA e o NOME da variavel; o valor nunca sai daqui.
function conferirSegredos({ raiz, git }) {
  const r = git(['ls-files']);
  const versionados = r.ok ? r.linhas : [];
  const complained = [];
  for (const relativo of versionados) {
    const completo = path.join(raiz, relativo);
    if (!fs.existsSync(completo)) continue;
    const texto = fs.readFileSync(completo, 'utf8');
    for (const [numero, linha] of texto.split('\n').entries()) {
      if (SEGMENTO_REAL.test(linha)) {
        complained.push(relativo + ':' + (numero + 1));
      }
    }
  }
  if (complained.length > 0) {
    return {
      ok: false,
      motivo: 'credencial real em ' + complained.join(', ')
        + ' — a linha e levantada, o valor nunca sai daqui',
    };
  }
  return {
    ok: true,
    motivo: versionados.length + ' arquivo(s) versionado(s), nenhum com credencial',
  };
}

// O padrao que a verificacao procura: o nome de uma variavel de credencial
// seguido de `:` ou `=` e de um valor que NAO parece didatico. Um
// `SUA_SENHA`, um `exemplo-do-material` ou um valor vazio nao casam — e nao
// podem casar, porque um verificador que accuse a senha de exemplo da propria
// documentacao e um verificador que ninguem usa.
const SEGMENTO_REAL = /(password|passwd|senha|secret|token|api[_-]?key)\s*[:=]\s*['"][^'"\n]{3,}['"]/i;
const NAO_E_SEGREDO = /^(vazia|nenhuma|invalida|invalido|errada|errado|exemplo|teste|placeholder|sua-senha|suasenha)/i;

// ============================================================= 3. o git
// `gitComandos(args, raiz)` roda o git na raiz da entrega e devolve `{ ok,
// linhas }`. Sem git na maquina, o verificador avisa e segue: a prova do git e
// uma parte da entrega, nao a entrega inteira.
function gitComandos(args, raiz) {
  const r = spawnSync('git', args, { cwd: raiz, encoding: 'utf8' });
  if (r.error) return { ok: false, linhas: ['(git indisponivel nesta maquina)'] };
  return {
    ok: r.status === 0,
    linhas: (r.stdout + r.stderr).split('\n').map((l) => l.trim()).filter(Boolean),
  };
}

// ============================================== 4. a entrega de verdade
//
// `montarEntrega(raiz, opcoes)` escreve a arvore de arquivos. E o mesmo codigo
// para as duas entregas do exemplo, com uma opcao de mudanca: a segunda
// entrega tem o `.env` no `.gitignore` e a primeira nao. E o que permite
// medir o mesmo checklist em duas condicoes.
//
// Nenhum valor de segredo real e escrito: o `.env` deste exemplo tem valores
// ficticios, e o `DB_PASS` dele e `exemplo-do-material` — o mesmo valor que a
// documentacao da aula 1 usa como chave de exemplo.
function montarEntrega(raiz, opcoes) {
  fs.mkdirSync(path.join(raiz, 'src'), { recursive: true });
  fs.mkdirSync(path.join(raiz, 'test'), { recursive: true });
  fs.mkdirSync(path.join(raiz, 'migrations'), { recursive: true });

  // ---------------------------------------------------------- o README.md
  // O conteudo do README e a CONFIGURACAO da entrega, e por isso que ele pode
  // aparecer na pagina: e um arquivo, nao saida de terminal. As secoes sao as
  // seis que o checklist exige, e cada uma responde a pergunta dela.
  const readme = [
    '# API de produtos',
    '',
    'Cadastro de produtos em MySQL, escrito com Node.js e o driver `mysql2`.',
    'Documentacao da API em `openapi.json`.',
    '',
    '## O que e',
    '',
    'Um servidor HTTP com tres rotas sobre a tabela `tb_produto`: listar,',
    'obter por id e cadastrar. Serve JSON, valida a entrada e traduz o erro do',
    'banco para um codigo que o cliente compara.',
    '',
    '## Instalacao',
    '',
    'Node 18 ou superior. Um servidor MySQL ou MariaDB com um usuario que',
    'crie banco e tabela. Nada mais: as duas dependencias estao no',
    '`package.json`.',
    '',
    '## Como rodar',
    '',
    '```',
    'npm install',
    'cp env.exemplo .env    # preencha com a credencial local',
    'npm run migrate        # cria o esquema',
    'npm start             # sobe o servidor',
    '```',
    '',
    '## Variaveis de ambiente',
    '',
    '| Variavel | Para que serve |',
    '|---|---|',
    '| `DB_HOST` | endereco do servidor do banco |',
    '| `DB_PORT` | porta do servidor do banco |',
    '| `DB_USER` | usuario do banco |',
    '| `DB_PASS` | senha do banco |',
    '| `DB_NAME` | nome do banco |',
    '| `NODE_ENV` | `development` ou `production` |',
    '',
    'Os valores ficam no `.env`, que nao e versionado. O modelo com os nomes',
    'vai no git: `env.exemplo`.',
    '',
    '## Migracoes',
    '',
    'Os arquivos em `migrations/` sao aplicados em ordem de nome, e cada um',
    'declara o que faz em `-- up` e como desfaz em `-- down`:',
    '',
    '```',
    'npm run migrate        # aplica o que falta',
    'npm run migrate:down   # desfaz a ultima',
    '```',
    '',
    '## Testes',
    '',
    '```',
    'npm test',
    '```',
    '',
    'O teste sobe o servidor em porta livre, faz as requisicoes e fecha tudo',
    'no `after`. Ele nao depende de ordem: cada caso comeca com o `TRUNCATE`.',
    '',
  ].join('\n');
  fs.writeFileSync(path.join(raiz, 'README.md'), readme);

  // ------------------------------------------------------- o package.json
  const pacote = {
    name: 'api-produtos',
    version: ['1', '0', '0'].join('.'),
    private: true,
    description: 'Cadastro de produtos em MySQL',
    type: 'commonjs',
    main: 'src/server.js',
    scripts: {
      start: 'node src/server.js',
      dev: 'node --watch src/server.js',
      test: 'node --test',
      migrate: 'node src/migrate.js',
      'migrate:down': 'node src/migrate.js --down',
    },
    dependencies: { mysql2: ['^3', '24', '5'].join('.') },
  };
  fs.writeFileSync(path.join(raiz, 'package.json'), JSON.stringify(pacote, null, 2) + '\n');

  // -------------------------------------------------- o src, com 3 arquivos
  // `produtoRepository.js` e o unico que fala com o banco; `produtoRota.js`
  // declara as rotas; `server.js` sobe. A separacao e o que permite testar
  // a rota sem subir servidor.
  fs.writeFileSync(path.join(raiz, 'src', 'produtoRepository.js'), [
    '// Repositorio de produto: e o unico arquivo que fala com o banco.',
    '// Nenhum outro sabe o nome da tabela nem a forma da consulta — e por isso',
    '// que mudar o SQL e uma mudanca de um arquivo so.',
    "'use strict';",
    '',
    'const { createPool } = require(\'mysql2/promise\');',
    '',
    'function criarPool(env) {',
    '  return createPool({',
    '    host: env.DB_HOST,',
    '    port: Number(env.DB_PORT),',
    '    user: env.DB_USER,',
    '    password: env.DB_PASS,',
    '    database: env.DB_NAME,',
    '  });',
    '}',
    '',
    'async function listar(pool, filtro) {',
    '  const sql = filtro',
    '    ? \'SELECT id, nm_item, qtd FROM tb_produto WHERE nm_item LIKE ? ORDER BY id\'',
    '    : \'SELECT id, nm_item, qtd FROM tb_produto ORDER BY id\';',
    '  const [linhas] = await pool.execute(sql, filtro ? [filtro] : []);',
    '  return linhas;',
    '}',
    '',
    'module.exports = { criarPool, listar };',
    '',
  ].join('\n'));

  fs.writeFileSync(path.join(raiz, 'src', 'produtoRota.js'), [
    '// As rotas do produto, como tabela de dados.',
    '// O caminho decide a resposta, e o `operationId` e o mesmo nome que o',
    '// `openapi.json` publica — e o que permite gerar um do outro.',
    "'use strict';",
    '',
    'const ROTAS = [',
    '  { metodo: \'GET\', caminho: \'/produtos\', operationId: \'listarProdutos\' },',
    '  { metodo: \'GET\', caminho: \'/produtos/{id}\', operationId: \'obterProduto\' },',
    '  { metodo: \'POST\', caminho: \'/produtos\', operationId: \'cadastrarProduto\' },',
    '];',
    '',
    'function casa(method, caminho) {',
    '  return ROTAS.find((r) => r.metodo === method && r.caminho.startsWith(caminho));',
    '}',
    '',
    'module.exports = { ROTAS, casa };',
    '',
  ].join('\n'));

  // O servidor da entrega tambem precisa fechar. O `close()` do teste e o
  // `SIGTERM` do process manager sao o mesmo cuidado, e um arquivo de entrega
  // que sobe servidor sem nunca fechar e o defeito que trava o processo.
  fs.writeFileSync(path.join(raiz, 'src', 'server.js'), [
    '// O servidor HTTP. Sobe, responde e fecha no SIGTERM do process manager.',
    '// O `listen(0)` nao entra aqui: em producao a porta vem de `PORT`, e no',
    '// teste vem do `listen(0)` — a escolha e de quem chama.',
    "'use strict';",
    '',
    'const http = require(\'node:http\');',
    'const { criarPool, listar } = require(\'./produtoRepository\');',
    'const { casa } = require(\'./produtoRota\');',
    '',
    'function criarServer(pool) {',
    '  return http.createServer(async (req, res) => {',
    '    if (!casa(req.method, req.url.split(\'?\')[0])) {',
    '      res.writeHead(404, { \'Content-Type\': \'application/json; charset=utf-8\' });',
    '      return res.end(JSON.stringify({ erro: \'ROTA_DESCONHECIDA\' }));',
    '    }',
    '    const linhas = await listar(pool, null);',
    '    res.writeHead(200, { \'Content-Type\': \'application/json; charset=utf-8\' });',
    '    res.end(JSON.stringify({ produtos: linhas }));',
    '  });',
    '}',
    '',
    '// `fechar` e o que segura o processo: sem ele o socket fica preso no',
    '// event loop e o `after` do teste passa mas trava. E o mesmo `close()` que',
    '// o exemplo de HTTP usa no `finally`.',
    'async function fechar(servidor) {',
    '  await new Promise((resolve) => servidor.close(resolve));',
    '}',
    '',
    'module.exports = { criarServer, fechar };',
    '',
  ].join('\n'));

  // ---------------------------------------------------------- o src do teste
  // Um teste que importa o servidor e o fecha no `after`: sem isso o processo
  // fica segurando o event loop e o teste passa mas trava. E `fecharServer` e o
  // par de `criarServer` — quem escreve a rota exporta o fechar, porque um
  // servidor que sobe e nao fecha segura o processo de quem teste.
  fs.writeFileSync(path.join(raiz, 'test', 'produtoRota.test.js'), [
    '// Teste da rota: sobe o servidor, faz a requisicao e fecha tudo no `after`.',
    '// Sem o `after`, o socket do banco segura o processo e o runner espera ate',
    '// o timeout — o teste passa e trava ao mesmo tempo.',
    "'use strict';",
    '',
    'const { describe, test, after } = require(\'node:test\');',
    'const assert = require(\'node:assert\');',
    'const { criarServer, fecharServer } = require(\'../src/server\');',
    'const { casa } = require(\'../src/produtoRota\');',
    '',
    'describe(\'produtoRota\', () => {',
    '  let servidor;',
    '',
    '  after(async () => {',
    '    await fecharServer(servidor);',
    '  });',
    '',
    '  test(\'caminho desconhecido devolve 404\', () => {',
    '    assert.ok(casa(\'GET\', \'/inexistente\') === undefined);',
    '  });',
    '});',
    '',
  ].join('\n'));

  // -------------------------------------------------------- as migracoes
  // Uma migracao por arquivo, com o prefixo de tres digitos que ordena, e o
  // par `-- up` / `-- down` que desfaz. A segunda cria uma tabela que a
  // primeira referencie: e por isso que a ordem importa.
  fs.writeFileSync(path.join(raiz, 'migrations', '001_criar_tb_produto.sql'), [
    '-- up',
    'CREATE TABLE IF NOT EXISTS tb_produto (',
    '  id      INT AUTO_INCREMENT PRIMARY KEY,',
    '  nm_item VARCHAR(40) NOT NULL UNIQUE,',
    '  qtd     INT NOT NULL DEFAULT 1',
    ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;',
    '',
    '-- down',
    'DROP TABLE IF EXISTS tb_produto;',
    '',
  ].join('\n'));

  fs.writeFileSync(path.join(raiz, 'migrations', '002_criar_tb_log.sql'), [
    '-- up',
    'CREATE TABLE IF NOT EXISTS tb_log (',
    '  id          INT AUTO_INCREMENT PRIMARY KEY,',
    '  dt_registro DATETIME NOT NULL,',
    '  ds_mensagem VARCHAR(200) NOT NULL',
    ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;',
    '',
    '-- down',
    'DROP TABLE IF EXISTS tb_log;',
    '',
  ].join('\n'));

  // -------------------------------------------------------- o env.exemplo
  // O modelo: NOMES com valor vazio. `NODE_ENV` tem valor porque nao e
  // segredo e tem um padrao util — e a unica excecao do arquivo.
  const modelo = [
    '# copie para .env e preencha com a credencial local.',
    '# os nomes vao no git; os valores, nao.',
    'DB_HOST=',
    'DB_PORT=',
    'DB_USER=',
    'DB_PASS=',
    'DB_NAME=',
    'NODE_ENV=development',
    '',
  ].join('\n');
  fs.writeFileSync(path.join(raiz, 'env.exemplo'), modelo);

  // -------------------------------------------------------------- o .env
  // O arquivo de credencial local. Os valores sao ficticios e servem para o
  // exemplo; em projeto de verdade vem do servidor de quem vai rodar.
  const local = [
    'DB_HOST=127.0.0.1',
    'DB_PORT=3306',
    'DB_USER=aluno',
    'DB_PASS=exemplo-do-material',
    'DB_NAME=api_produtos',
    'NODE_ENV=development',
    '',
  ].join('\n');
  fs.writeFileSync(path.join(raiz, '.env'), local);

  // --------------------------------------------------------- o .gitignore
  // A DIFERENCA ENTRE AS DUAS ENTREGAS ESTA NESTE ARQUIVO. A segunda entrega
  // recebe `node_modules/`, `*.log` e a pasta do banco local; a primeira
  // recebe so `node_modules/` e deixa o `.env` passar.
  const ignorados = opcoes.protegido
    ? ['node_modules/', '.env', '.env.*', '!env.exemplo', '*.log', 'dados/']
    : ['node_modules/'];
  fs.writeFileSync(path.join(raiz, '.gitignore'), ignorados.join('\n') + '\n');

  // ------------------------------------------------ o banco local do exemplo
  // `dados/` guarda o dump do banco de desenvolvimento. Ela e reconstruivel, e
  // por isso que entra no `.gitignore` junto com o `node_modules`.
  if (opcoes.protegido) {
    fs.mkdirSync(path.join(raiz, 'dados'), { recursive: true });
    fs.writeFileSync(path.join(raiz, 'dados', 'dump-local.sql'),
      '-- dump local, reconstruivel com npm run migrate\n');
  }

  // ------------------------------------------------------- o primeiro commit
  // `git init`, um `git add .` e um commit. O `add .` e proposital: e o comando
  // que pega o `.env` quando o `.gitignore` nao recusa, e e o que a auditoria
  // do exemplo vai medir.
  function git(args) {
    return gitComandos(args, raiz);
  }
  git(['init', '-q']);
  // `user.name` e `user.email` sao configurados aqui porque a maquina de quem
  // roda o exemplo pode nao ter: sem os dois, o `commit` sai com codigo 1, a
  // arvore fica sem nenhum commit e o `git log` do exemplo imprimiria vazio.
  git(['config', 'user.name', 'Entrega do material']);
  git(['config', 'user.email', '[email protected]']);
  // O `add .` sem lista de arquivos e proposital: e o comando que pega o
  // `.env` quando o `.gitignore` nao recusa, e e exatamente o que a auditoria
  // do exemplo mede nas duas arvores.
  git(['add', '.']);
  git(['commit', '-q', '-m', 'primeira entrega da API de produtos']);
}

// ============================================ 5. o que ENTROU no repositorio
//
// `auditarGit(git)` e a parte do exemplo que responde "o segredo foi comitado?".
// Sao dois comandos, e eles medem coisas diferentes:
//
//   `git check-ignore -v .env`  -> o que o .gitignore RECUSA (regra aplicada agora)
//   `git ls-files .env`         -> o que o git REALMENTE versiona (o que ja foi)
//
// A diferenca entre os dois e o que separa uma entrega segura de uma entrega
// comprometida — e e o que o exemplo mede nas duas arvores.
function auditarGit(git) {
  const ignorado = git(['check-ignore', '-v', '.env']);
  const versionado = git(['ls-files', '.env']);
  const modelo = git(['ls-files', 'env.exemplo']);

  return {
    // `ok: 1` = a regra existe (o git SAI com 0). `ok: 0` = nao existe.
    recusado: ignorado.ok,
    regra: ignorado.ok ? ignorado.linhas[0] : '(nenhuma regra recusa o .env)',
    // `versoes.length > 0` = o .env ESTA no repositorio.
    versoes: versionado.linhas,
    temModelo: modelo.linhas.length > 0,
  };
}

// ==================================================== 6. a entrega e a auditoria
async function main() {
  // As duas arvores temporarias sao criadas ANTES do `try` e limpas no
  // `finally`. A entrega de verdade esta em `/tmp`, com um `.git` dentro, e um
  // erro no meio da auditoria deixaria as duas para tras.
  const raizA = fs.mkdtempSync(path.join(os.tmpdir(), 'entrega-sem-'));
  const raizB = fs.mkdtempSync(path.join(os.tmpdir(), 'entrega-com-'));
  try {
    await conferirEntregas(raizA, raizB);
  } finally {
    limparArvores(raizA, raizB);
    console.log('\nas duas arvores temporarias foram removidas, mesmo se algo falhou.');
  }
}

async function conferirEntregas(raizA, raizB) {
  // A versao do banco e capturada, nunca afirmada: a maquina de quem roda o
  // exemplo devolve o que ela tem, e o `README` nao pode escrever um numero que
  // so vale aqui.
  const banco = await createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
  });
  // O `finally` fecha a conexao mesmo se a auditoria falhar no meio. O socket do
  // banco segura o event loop, e sem este `finally` o exemplo trava ate o
  // timeout do portao — com a saida inteira, que parece um exemplo bom.
  try {
    await auditar(banco, raizA, raizB);
  } finally {
    await banco.end();
  }
}

async function auditar(banco, raizA, raizB) {
  const [versao] = await banco.query('SELECT VERSION() AS versao');
  console.log('banco em uso: ' + versao[0].versao);

  // O banco de teste do exemplo. `IF NOT EXISTS` e `TRUNCATE` no comeco: rodar
  // duas vezes tem que dar o mesmo resultado, que e o que o `CONTRATO.md`
  // exige de todo exemplo.
  await banco.query(`
    CREATE TABLE IF NOT EXISTS tb_d14a2_entrega (
      id          INT AUTO_INCREMENT PRIMARY KEY,
      nm_item     VARCHAR(40) NOT NULL,
      ds_checklist VARCHAR(60) NOT NULL,
      fl_passa    TINYINT NOT NULL
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  `);
  await banco.query('TRUNCATE TABLE tb_d14a2_entrega');

  // -------------------------------------------------- a entrega DUAS vezes
  // Duas arvores temporarias, montadas pelo mesmo `montarEntrega`. A diferenca
  // esta no `.gitignore`: a primeira deixa o `.env` passar, a segunda recusa.
  console.log('\n--- 1. duas entregas, o mesmo codigo, um .gitignore de diferenca ---');
  // O caminho NAO e impresso: `mkdtempSync` sorteia um nome novo a cada
  // execucao, e a pagina mostraria um caminho que o aluno nunca vai ver na
  // propria maquina. O que identifica a arvore e a condicao, nao o nome.
  console.log('entrega A: .env SEM regra no .gitignore');
  console.log('entrega B: .env COM regra no .gitignore');

  montarEntrega(raizA, { protegido: false });
  montarEntrega(raizB, { protegido: true });

  // ------------------------------------------------------- o checklist item a item
  console.log('\n--- 2. o checklist de entrega, item por item ---');
  // A largura da primeira coluna vem do proprio item mais comprido: escrever
  // um numero fixo desalinha a tabela no primeiro texto que passa do limite, e
  // a coluna do SIM/NAO e o que o olho procura.
  const LARGURA = Math.max(...CHECKLIST.map((c) => c.item.length)) + 2;
  console.log('item'.padEnd(LARGURA) + 'A      B');
  const resultados = { A: [], B: [] };

  for (const entrada of CHECKLIST) {
    const linha = { id: entrada.id, item: entrada.item };
    for (const [nome, raiz] of [['A', raizA], ['B', raizB]]) {
      const { git } = gitPorRaiz(raiz);
      const r = entrada.resposta({ raiz, git });
      linha[nome] = r;
      resultados[nome].push(linha);
      // TODA verificacao vai para o banco, passou ou nao: uma tabela que so
      // guarda o que deu certo nao serve para comparar as duas entregas.
      await banco.execute(
        'INSERT INTO tb_d14a2_entrega (nm_item, ds_checklist, fl_passa) VALUES (?, ?, ?)',
        [entrada.id, nome, r.ok ? 1 : 0]);
    }
    console.log(entrada.item.padEnd(LARGURA) + (linha.A.ok ? '  SIM' : '  NAO')
      + '      ' + (linha.B.ok ? 'SIM' : 'NAO'));
  }

  // O motivo de cada NAO, que e o que a pessoa precisa para corrigir. Sem o
  // motivo, o checklist diz "NAO" e nao diz o que fazer — e ai ele nao serve
  // para nada.
  console.log('\n--- 3. o que precisa ser corrigido em cada entrega ---');
  for (const [nome, lista] of [['A', resultados.A], ['B', resultados.B]]) {
    const ruins = lista.filter((l) => !l[nome].ok);
    console.log('\nentrega ' + nome + ': ' + (ruins.length === 0
      ? 'nada a corrigir'
      : ruins.length + ' item(ns)'));
    for (const r of ruins) {
      console.log('  [' + r.id + '] ' + r[nome].motivo);
    }
  }

  // ================================================== 4. o que o git respondeu
  console.log('\n--- 4. o que ENTROU no repositorio (git de verdade) ---');
  for (const [nome, raiz] of [['A', raizA], ['B', raizB]]) {
    const { git } = gitPorRaiz(raiz);
    const auditoria = auditarGit(git);
    console.log('\nentrega ' + nome + ':');
    console.log('  git check-ignore -v .env (saida ' + (auditoria.recusado ? 0 : 1) + '):');
    for (const l of auditoria.regra.split('\n')) console.log('    ' + l);
    console.log('  git ls-files .env     : '
      + (auditoria.versoes.length
        ? auditoria.versoes.join(', ') + '   <-- O .env ESTA NO REPOSITORIO'
        : '(nenhuma linha) — o .env nao esta versionado'));
    console.log('  git ls-files env.exemplo : '
      + (auditoria.temModelo ? 'env.exemplo   (o modelo esta versionado, como deve)'
        : '(ausente) — o modelo com os nomes deveria estar'));
  }

  // --------------------------------- 5. a entrega comprometida: e o que fazer
  console.log('\n--- 5. quando o .env JA foi comitado: apagar o arquivo nao resolve ---');
  const { git } = gitPorRaiz(raizA);
  console.log('a entrega A tem o .env versionado. Vamos agir como quem fez isso:');
  console.log('\n1) rm .env && git add . && git commit -m "remove o .env"');
  fs.unlinkSync(path.join(raizA, '.env'));
  git(['rm', '-q', '--cached', '.env']);
  git(['commit', '-q', '-m', 'remove o .env']);
  const depoisDeApagar = git(['ls-files', '.env']);
  console.log('   git ls-files .env agora: '
    + (depoisDeApagar.linhas.length
      ? depoisDeApagar.linhas.join(', ')
      : '(nenhuma linha) — saiu do indice'));

  // Apagou o arquivo do indice. E o `check-ignore` ainda recusa? Sem regra, nao.
  const regraDepois = git(['check-ignore', '-v', '.env']);
  console.log('\n2) git check-ignore -v .env (saida ' + (regraDepois.ok ? 0 : 1) + '):');
  for (const l of (regraDepois.ok ? regraDepois.linhas : ['(nenhuma regra: o arquivo pode voltar)'])) {
    console.log('    ' + l);
  }

  // O commit novo nao tem o arquivo, mas o ANTIGO tem. E aqui que o exemplo
  // mostra o ponto que a documentacao promete: o valor continua no historico.
  // O hash muda a cada execucao — e o mesmo caso da porta do servidor. O que
  // interessa e a CONTAGEM e a mensagem dos commits, e as duas sao estaveis.
  const historico = git(['log', '--format=%s']);
  console.log('\n3) os commits que ainda existem: ' + historico.linhas.length
    + ' (o hash muda a cada execucao, a mensagem nao):');
  for (const l of historico.linhas) console.log('    ' + l);
  const procurando = git(['log', '--all', '--full-history', '--', '.env']);
  console.log('\n4) o .env ainda aparece em algum commit?');
  console.log('    git log --all --full-history -- .env  ->  '
    + (procurando.linhas.length
      ? procurando.linhas.length + ' linha(s) — o arquivo EXISTE no historico'
      : '(nenhuma linha)'));
  console.log('    a senha nao sumiu: ela esta no conteudo do commit antigo,');
  console.log('    e qualquer pessoa com o repositorio clonado la le com `git show`.');
  console.log('\n5) a correcao que resolve, nesta ordem:');
  console.log('    a) trocar a senha do banco — e o unico passo que desfaz o vazamento');
  console.log('    b) reescrever o historico (git filter-repo ou BFG) e forcar o push');
  console.log('    c) avisar quem clona antes de forcar: quem ja clonou tem a copia');
  console.log('\napagar o arquivo resolve o ARQUIVO. trocar a senha resolve o VALOR.');
  console.log('sao coisas diferentes, e so a segunda desfaz o vazamento.');

  // ================================================ 6. o que o banco registrou
  // Agrupado pelo ITEM e nao pela entrega: cada item foi conferido duas vezes,
  // uma em cada arvore, e a conta que interessa e quantas vezes o mesmo item
  // passou e quantas reprovou. Uma linha por item mostra que nove items
  // passaram nas duas entregas e um so reprovou — em uma delas.
  const [porItem] = await banco.execute(
    'SELECT nm_item, SUM(fl_passa) AS passou, SUM(1 - fl_passa) AS falhou '
    + 'FROM tb_d14a2_entrega GROUP BY nm_item ORDER BY nm_item');
  console.log('\n--- 6. o mesmo checklist, conferido nas duas entregas ---');
  console.log('item                      passou  falhou');
  for (const linha of porItem) {
    console.log(linha.nm_item.padEnd(27)
      + String(linha.passou).padStart(5) + String(linha.falhou).padStart(9));
  }
  const [total] = await banco.query(
    'SELECT COUNT(*) AS n, SUM(fl_passa) AS passou, SUM(1 - fl_passa) AS falhou '
    + 'FROM tb_d14a2_entrega');
  console.log('\nverificacoes: ' + total[0].n + ' | aprovaram: ' + total[0].passou
    + ' | reprovaram: ' + total[0].falhou);
  const soReprovou = porItem.filter((l) => l.falhou > 0).map((l) => l.nm_item);
  console.log('\nitem que reprovou em alguma entrega: ' + soReprovou.length
    + (soReprovou.length ? ' (' + soReprovou.join(', ') + ')' : ''));
  console.log('todo o resto do codigo, do README, das migracoes e dos testes e');
  console.log('identico nas duas arvores — e a entrega inteira depende de um');
  console.log('arquivo de tres linhas que ninguem abre depois do primeiro commit.');
}

// As duas arvores sao temporarias e nao precisam sobreviver ao exemplo. O
// `rmSync` fica num `finally` do `main`, e nao no fim do caminho feliz: um
// erro no meio da auditoria deixaria duas arvores com `.git` e um `.env`
// (ficticio) em `/tmp` de quem rodou.
function limparArvores(...raizes) {
  for (const raiz of raizes) {
    try {
      fs.rmSync(raiz, { recursive: true, force: true });
    } catch (_) {
      // A pasta temporaria fica orfa e o sistema limpa sozinho: nao vale
      // reprovar um exemplo que rodou por causa da limpeza do fim.
    }
  }
}

// `gitPorRaiz(raiz)` devolve o mesmo `gitComandos` ja amarrado na raiz. Existe
// para que cada item do checklist fale com o git DA SUA entrega, e nao com o
// git do primeiro `raiz` que apareceu no codigo.
function gitPorRaiz(raiz) {
  return { git: (args) => gitComandos(args, raiz) };
}

main().catch((erro) => {
  console.error('falhou:', erro.code || erro.name, '-', erro.message);
  process.exit(1);
});

Saída real

banco em uso: 10.11.14-MariaDB-0ubuntu0.24.04.1

--- 1. duas entregas, o mesmo codigo, um .gitignore de diferenca ---
entrega A: .env SEM regra no .gitignore
entrega B: .env COM regra no .gitignore

--- 2. o checklist de entrega, item por item ---
item                                                       A      B
README.md tem o que e, instalar, rodar e variaveis           SIM      SIM
.env esta no .gitignore                                      NAO      SIM
env.exemplo existe, com os NOMES e o valor vazio             SIM      SIM
migracao com ordem, up e down, e a ordem no README           SIM      SIM
os testes existem e o package.json tem o script              SIM      SIM
o package.json tem scripts de start, dev e test              SIM      SIM
nenhum nome de simbolo do sistema, nenhum arquivo gigante    SIM      SIM
nenhum codigo morto, nenhuma dependencia desnecessaria       SIM      SIM
todo arquivo tem comentario que explica o que faz            SIM      SIM
nenhum segredo em nenhum arquivo versionado                  SIM      SIM

--- 3. o que precisa ser corrigido em cada entrega ---

entrega A: 1 item(ns)
  [gitignore-env] o git NAO recusa o .env: nada no .gitignore casa com ele

entrega B: nada a corrigir

--- 4. o que ENTROU no repositorio (git de verdade) ---

entrega A:
  git check-ignore -v .env (saida 1):
    (nenhuma regra recusa o .env)
  git ls-files .env     : .env   <-- O .env ESTA NO REPOSITORIO
  git ls-files env.exemplo : env.exemplo   (o modelo esta versionado, como deve)

entrega B:
  git check-ignore -v .env (saida 0):
    .gitignore:2:.env	.env
  git ls-files .env     : (nenhuma linha) — o .env nao esta versionado
  git ls-files env.exemplo : env.exemplo   (o modelo esta versionado, como deve)

--- 5. quando o .env JA foi comitado: apagar o arquivo nao resolve ---
a entrega A tem o .env versionado. Vamos agir como quem fez isso:

1) rm .env && git add . && git commit -m "remove o .env"
   git ls-files .env agora: (nenhuma linha) — saiu do indice

2) git check-ignore -v .env (saida 1):
    (nenhuma regra: o arquivo pode voltar)

3) os commits que ainda existem: 2 (o hash muda a cada execucao, a mensagem nao):
    remove o .env
    primeira entrega da API de produtos

4) o .env ainda aparece em algum commit?
    git log --all --full-history -- .env  ->  8 linha(s) — o arquivo EXISTE no historico
    a senha nao sumiu: ela esta no conteudo do commit antigo,
    e qualquer pessoa com o repositorio clonado la le com `git show`.

5) a correcao que resolve, nesta ordem:
    a) trocar a senha do banco — e o unico passo que desfaz o vazamento
    b) reescrever o historico (git filter-repo ou BFG) e forcar o push
    c) avisar quem clona antes de forcar: quem ja clonou tem a copia

apagar o arquivo resolve o ARQUIVO. trocar a senha resolve o VALOR.
sao coisas diferentes, e so a segunda desfaz o vazamento.

--- 6. o mesmo checklist, conferido nas duas entregas ---
item                      passou  falhou
env-example                    2        0
gitignore-env                  1        1
legivel                        2        0
migracao                       2        0
morto                          2        0
nomes                          2        0
readme                         2        0
scripts                        2        0
segredo                        2        0
testes                         2        0

verificacoes: 20 | aprovaram: 19 | reprovaram: 1

item que reprovou em alguma entrega: 1 (gitignore-env)
todo o resto do codigo, do README, das migracoes e dos testes e
identico nas duas arvores — e a entrega inteira depende de um
arquivo de tres linhas que ninguem abre depois do primeiro commit.

as duas arvores temporarias foram removidas, mesmo se algo falhou.