Dia 14 — Documentação e entrega
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:
| Pergunta | Onde 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,versionedescriptionda API. Aversionaqui é 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 é oopenapi.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. Editaropenapi.jsonna 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.
| Arquivo | Pergunta que responde | Vai para o git |
|---|---|---|
README.md | o que é isto e como eu subo | sim |
openapi.json | como eu chamo cada rota | sim, gerado do código |
x-como-rodar | instalação, passos e formato da chave | sim, dentro do openapi.json |
A versão do banco não vai no
READMEnem noopenapi.jsonescrevida à 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 comSELECT VERSION()e oREADMEdiz "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 UIaparece 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 imprime0/3nos parâmetros e0/10nos 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().
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:
| Arquivo | Pergunta que responde | Vai para o git |
|---|---|---|
README.md | o que é isto, e como eu subo | sim |
env.exemplo | que variáveis eu preciso preencher | sim, só os nomes |
.gitignore | o que não entra | sim |
migrations/ | como nasce o esquema | sim |
test/ | como eu sei que funciona | sim |
package.json | com que comando cada coisa roda | sim |
.env | qual é a credencial desta máquina | nunca |
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 comnpm ci, e é por isso que nunca é versionado;.envrecusa o arquivo de credencial;.env.*recusa.env.producao, que é o jeito mais comum de vazar: o.envestá protegido e o.env.producao, que tem a senha de verdade, não;!env.exemplovolta 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:
- trocar a senha do banco — é o único passo que desfaz o vazamento;
- reescrever o histórico (
git filter-repoou BFG) e forçar o push; - 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.
.gitignoresó vale para arquivo ainda não commitado. Regra de.gitignorenunca 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 nomedescritivo —produtoRepository.js, nãoauxiliar.jsnemutils.js. Nome genérico é dívida: quando seis arquivos se chamamutils.js, ninguém sabe qual editar, e o git junta os dois no mesmo diff.tamanho de arquivocom 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çãonumerada —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 omotivoé 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.