Olá, sou Vitor Delfino e como muitos de vocês que cairam aqui, sou um dev.
Faz um tempo que tenho a vontade de começar a escrever alguns posts, mas nunca tomava essa iniciativa. Que em 2021 seja um pouco diferente.
Bora lá !
Após diversos projetos criados, tanto profissionalmente quanto para aprendizado, cheguei a uma estrutura interessante de projetos com Node e decidi compartilha-lo em alguns posts.
O que eu costumo utilizar:
- Node.js + Typescript
- Typeorm
- Jest
Com apenas isso, já é possível desenvolver muita coisa.
Maaaas, para deixar o projeto de exemplo um pouco mais profissional, algo parecido com o que encontrariamos em uma empresa, vou escrever um exemplo mais completo, com testes mocados, documentação com swagger, separação das variáveis por ambiente e utilizar recursos externos com Docker (mongoDb, Redis, Rabbit, etc...) que é algo que sempre senti falta nos tutorias.
Neste primeiro post, irei montar toda a estrutura das pastas e configurar o ESLint, Commitlint, tsconfig.json e algumas outras coisinhas.
Iniciando o projeto
Vamos lá, primeiramente criamos a pasta e inicializamos o projeto.
Eu costumo utilizar yarn.
mkdir example
cd example
yarn init -y
code .
E depois instalamos algumas dependências.
Plugins e mais plugins
Primeiramente instalo o eslint e o inicio com as seguintes opções.
yarn add eslint -D
yarn eslint --init
Como o eslint instala os pacotes utilizando npm, eu simplemente apago o arquivo package-lock.json e rodo o comando yarn.
E para incrementar as nossas regras, instalamos mais alguns plugins.
yarn add eslint-config-prettier eslint-import-resolver-typescript eslint-plugin-import-helpers eslint-plugin-prettier prettier typescript -D
E alteramos o eslint.json com as seguintes configurações.
{
"env": {
"es2021": true,
"node": true
},
"extends": [
"airbnb-base",
"plugin:@typescript-eslint/recommended",
"prettier",
"prettier/@typescript-eslint"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"prettier",
"eslint-plugin-import-helpers"
],
"rules": {},
"settings": {
"import/resolver": {
"typescript": {
"directory": "./tsconfig.json"
},
"node": {
"paths": [
"src"
],
"extensions": [
".ts"
]
}
}
}
}
Configurando alguns arquivos
Próximo passo, configuramos o tscofig.json
, .prettierrc
e .editorconfig
.preetierrc
{
"singleQuote": true,
"trailingComma": "es5",
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
]
}
tsconfig.json
{
"compilerOptions": {
"target": "es2017",
"lib": [
"es2019.array"
],
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"removeComments": true,
"strict": true,
"sourceMap": true,
"allowJs": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"suppressImplicitAnyIndexErrors": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@apps/*": [
"./src/apps/*"
],
"@config/*": [
"./src/config/*"
],
"@helper/*": [
"./src/helper/*"
],
"@middlewares/*": [
"./src/middlewares/*"
],
"@tools/*": [
"./src/tools/*"
]
},
"typeRoots": [
"./src/@types",
"node_modules/@types"
]
},
"include": [
"./src/**/*",
".vscode/@types"
],
"exclude": [
"node_modules",
"dist",
"logs",
"coverage"
]
}
Por enquanto, ignore o campo paths, irei explicar um pouco pra frente.
.editorconfig
Veja mais detalhes sobre esse cara aqui
root = true
[*]
end_of_line = lf
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
E até o momento temos a seguinte estrutura:
Geralmente o start de projetos é realmente chato, mas com o tempo isso vai ficando mais automático, e a gente acaba aproveitando muitas configurações de projetos anteriores.
Então vamos para um pouco de código.
Lets code!
Começamos instalando o necessário para a configuração do express e para o start da api.
Algumas libs que vamos utilizar.
- express-async-errors
- express-handler-errors Essa eu mesmo criei, para lidar de uma forma mais simplificada com os erros retornados pela API.
- express-request-id para adicionar um uuid na request
- continuation-local-storage para capturar o id da request nos serviços e nos logs
- cors
- dotenv para separação das environments
- morgan-body para logar o conteúdo das requests
- winston para logs
yarn add express cors dotenv continuation-local-storage express-async-errors express-handler-errors express-request-id morgan-body winston && yarn add @types/express @types/cors @types/dotenv @types/node @types/winston @types/continuation-local-storage
O primeiro arquivo que eu começo escrevendo, é o arquivo que configura todas as variáveis de ambiente utilizada pela aplicação.
Fica na seguinte estrutura: /src/config/index.ts
E é aqui que utilizamos a lib dotenv
/src/config/index.ts
import { config } from 'dotenv';
/*
* Aqui estamos dizendo para o dotenv
* onde ele deve buscar as variáveis de ambiente
* NODE_ENV será o stage da nossa aplicação [dev, qa, prod, local, etc...]
*/
const envfile = `.env.${process.env.NODE_ENV}`;
const envdir = process.cwd();
config({ path: `${envdir}/${envfile}` });
export const server = {
port: process.env.PORT,
env: process.env.NODE_ENV,
}
Até aqui, já podemos observar que o eslint está reclamando de algumas coisas, então vamos atualizar as configs.
adicione as regras no campo rules
eslintrc.json
"rules": {
"prettier/prettier": "error",
"global-require": "off",
"no-new": "off",
"no-console": "off",
"import/prefer-default-export": "off",
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"ts": "never"
}
],
"import-helpers/order-imports": [
"warn",
{
"newlinesBetween": "always", // new line between groups
"groups": [
"module",
"/^@config/",
"/^@apps/",
"/^@services/",
"/^@helper/",
"/^@/",
[
"parent",
"sibling",
"index"
]
],
"alphabetize": {
"order": "asc",
"ignoreCase": true
}
}
]
},
Vamos escrever agora as configurações do winston
Esse vai ser nosso primeiro middleware.
src/middlwares/logger.ts
import { getNamespace } from 'continuation-local-storage';
import winston from 'winston';
const options = {
console: {
level: 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
prettyPrint: true,
colorize: process.stdout.isTTY,
},
};
const logger = winston.createLogger({
transports: [new winston.transports.Console(options.console)],
exitOnError: false,
});
const formatMessage = (message: string) => {
// É aqui que resgatamos o id da requisição que será armazenado em um storage
const namespace = getNamespace('request');
const id = namespace && namespace.get('id');
return id ? `[${id}] ${message}` : message;
};
/**
* aqui devolvemos os níveis do log
* formatando a mensagem com o id da requisição caso exista
*/
export default {
log: (message: string): winston.Logger => logger.info(message),
info: (message: string, obj?: any): winston.Logger =>
logger.info(formatMessage(message), obj),
error: (message: string, obj?: any): winston.Logger =>
logger.error(formatMessage(message), obj),
warn: (message: string, obj?: any): winston.Logger =>
logger.warn(formatMessage(message), obj),
debug: (message: string, obj?: any): winston.Logger =>
logger.debug(formatMessage(message), obj),
silly: (message: string, obj?: any): winston.Logger =>
logger.silly(formatMessage(message), obj),
};
E agora algumas configurações do express
src/app.ts
import { Namespace, createNamespace } from 'continuation-local-storage';
import cors from 'cors';
import express, {
Application,
NextFunction,
Request,
RequestHandler,
Response,
} from 'express';
import { ErrorHandler } from 'express-handler-errors';
import morgan from 'morgan-body';
import logger from '@middlewares/logger';
class App {
public readonly app: Application;
private readonly session: Namespace;
constructor() {
this.app = express();
this.session = createNamespace('request'); // é aqui que vamos armazenar o id da request
this.middlewares();
this.errorHandle();
}
/**
* Aqui nos configuramos os middlewares
*/
private middlewares(): void {
this.app.use(express.json());
this.app.use(cors());
const reqId = require('express-request-id'); // essa lib não tem tipagem
this.app.use(reqId());
const attachContext: RequestHandler = (
_: Request,
__: Response,
next: NextFunction
) => {
this.session.run(() => next());
};
const setRequestId: RequestHandler = (
req: Request,
_: Response,
next: NextFunction
) => {
this.session.set('id', req.id);
next();
};
// Toda vez que chegar um request, nós armazenamos o id dela em um storage
this.app.use(attachContext, setRequestId);
morgan(this.app, {
noColors: true,
prettify: false,
logReqUserAgent: false,
stream: {
write: (msg: string) => logger.info(msg) as any,
},
});
}
/**
* Aqui é a configuração da lib para tratar os error
*/
private errorHandle(): void {
this.app.use(
(err: Error, _: Request, res: Response, next: NextFunction) => {
new ErrorHandler().handle(err, res, next, logger as any);
}
);
}
}
export default new App();
Por enquanto o arquivo fica assim, ainda adicionaremos algumas outras configurações.
E se repararmos no import de nossas dependências, conseguimos buscar as pastas utilizando @, é por conta da configuração dos paths no tsconfig.json
Mas ainda há um problema de tipagem nas configurações do express.
Por padrão, a interface Request do express não conhece o campo id que adicionamos nela. Então vamos sobrescrever essa interface.
/src/@types/express/index.d.ts
declare namespace Express {
interface Request {
id: string;
}
}
No nosso tsconfig.json
nós adicionamos o path /src/@types como um caminho para definição de tipos
tsconfig.json
...
"include": [
"./src/**/*",
".vscode/@types"
],
Agora vamos configurar o arquivo que irá iniciar a nossa aplicação.
src/server.ts
import { server } from '@config/index';
import logger from '@middlewares/logger';
import express from './app';
express.app.listen(server.port, () => {
logger.info('Server running', { port: server.port, mode: server.env });
});
Nossa aplicação está quase pronta para ser iniciada, mas como estamos utilizando typescript, precisamos transpilar todos os nossos arquivos, só assim o Node conseguirá entender o que queremos que ele execute.
E também tem um outro ponto, quando o transpilador encontrar um import nomeado com @middlewares/logger por exemplo, ele precisa entender exatamente onde buscar o arquivo.
Então utilizaremos mais duas bibliotecas para lidar com o build e transpile da aplicação.
Vamos aos downloads
yarn add @babel/cli @babel/core @babel/node @babel/plugin-proposal-class-properties @babel/plugin-proposal-decorators @babel/preset-env @babel/preset-typescript babel-eslint babel-plugin-module-resolver babel-plugin-transform-typescript-metadata ts-node-dev tsconfig-paths
E mais algumas configurações...
babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
plugins: [
'babel-plugin-transform-typescript-metadata',
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
[
'module-resolver',
{
// aqui que ensinamos ele onde buscar os imports
// e também ja podemos ter uma ideia de como irá ficar nossa estrutura de pastas
alias: {
'@apps': './src/apps',
'@config': './src/config',
'@helper': './src/helper',
'@middlewares': './src/middlewares',
'@shared': './src/shared',
'@tools': './src/tools',
'@services': './src/services',
'@utils': './src/utils',
},
},
],
],
ignore: ['**/*.spec.ts'],
};
Vamos adicionar o script de start
package.json
...
"scripts": {
"start:dev": "NODE_ENV=dev ts-node-dev -r tsconfig-paths/register --respawn --transpile-only --ignore-watch node_modules --no-notify src/server.ts"
}
...
Nesse momento, a aplicação já até pode ser iniciada utilizando o comando
yarn start:dev
Porém, nós não configuramos a porta que ficará escutando as requisições e também nenhuma rota.
Vamos lá!
Primeiro, nossa única variável de ambiente até o momento
.env.dev
PORT=3000
Agora, vamos configurar um arquivo de rotas e conecta-lo ao express
src/routes.ts
import { Request, Response, Router } from 'express';
import logger from '@middlewares/logger';
const route = Router();
route.get('/hello', (_: Request, res: Response) => {
logger.info(`Request recebida`);
res.json({ message: 'Hello World' });
});
export default route;
criaremos um método para plugar as rotas e chamaremos no construtor
src/app.ts
...
class App {
public readonly app: Application;
private readonly session: Namespace;
constructor() {
this.app = express();
this.session = createNamespace('request'); // é aqui que vamos armazenar o id da request
this.middlewares();
// chamada do método
// primeiro configuramos as rotas e depois o error handler
this.routes();
this.errorHandle();
}
...
/**
* configuração de rota
*/
private routes(): void {
this.app.use('/api', routes);
}
}
export default new App();
Agora se subirmos a api e chamarmos no navegador http://localhost:3000/api/hello, teremos o seguinte resultado
O que podemos notar no log do console:
- o endoint que foi chamado
Request: GET /api/hello at Sat Jan 09 2021 17:21:53 GMT-030
- o log que adicionamos
Request recebida
- o que a nossa api devolveu
Response Body:{"message":"Hello World"}
- o id da requisição do inicio de toda linha logada
fc410867-6fb3-4637-b771-7334c2f12781
O responsável por logar as informações do request é o Morgan que configuramos no arquivo src/app.ts
E como ficou a visão da request no navegador
Primeiro commit
Agora já estamos prontos para realizar o primeiro commit, mas antes disso, vamos escrever mais uma configuração.
Vamos usar o commitlint e o commitzen, para desde de o início todos os commits já seguir um padrão
Também vamos utilizar o husky para executar alguns scripts antes de cada commit. Por exemplo, no futuro antes de cada commit, vamos rodar a switch de testes para garantir que nada suba quebrado para o repositório.
Execute os camandos:
npx commitizen init cz-conventional-changelog --save-dev --save-exact
npm install --save-dev @commitlint/{cli,config-conventional}
yarn add -D husky
rm -rf node_modules
yarn
Crie o arquivo commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS',
};
Veremos que no arquivo package.json
terá agora uma configuração nova.
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
adicionamos também alguns scripts e a configuração do husky
{
"name": "example",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start:dev": "NODE_ENV=dev ts-node-dev -r tsconfig-paths/register --respawn --transpile-only --ignore-watch node_modules --no-notify src/server.ts",
"commit": "git-cz" // um script para abrir uma interface de commit
},
"devDependencies": {
...
},
"dependencies": {
...
},
// configuração do husk
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}
E podemos fazer o primeiro commit.
Vamos inicializar o git e configurar .gitignore
.
git init
.gitignore
node_modules
Note que se tentarmos escrever uma mensagem sem padrão no commit, receberemos um erro.
Leia mais sobre a convenção de mensagens aqui
Para facilitar escrever o commit, instalamos a lib commitzen
Vamos testa-la:
A lib nos oferece uma CLI no terminal, e fica bem mais fácil seguir o padrao de mensagens.
Considerações finais
Sei que esse primeiro post ficou realmente grande, mas espero ter conseguido detalhar bem o passo a passo da criação de uma api, um pouco mais robusta, com mais configurações, algo mais parecido com a vida real.
Até o momento, nossa estrutura de pastas está assim.
No próximo post, iremos configurar Typeorm para conectar com o banco de dados, e escrever o primeiro CRUD.
Top comments (0)