Como criar uma API REST Node.js/TypeScript, Parte 1: Express.js

Publicados: 2022-03-11

Como escrevo uma API REST no Node.js?

Ao criar um back-end para uma API REST, o Express.js geralmente é a primeira escolha entre as estruturas Node.js. Embora também suporte a criação de HTML e templates estáticos, nesta série, focaremos no desenvolvimento de back-end usando TypeScript. A API REST resultante será aquela que qualquer estrutura de front-end ou serviço de back-end externo poderá consultar.

Você precisará:

  • Conhecimento básico de JavaScript e TypeScript
  • Conhecimento básico de Node.js
  • Conhecimento básico da arquitetura REST (consulte esta seção do meu artigo anterior da API REST, se necessário)
  • Uma instalação pronta do Node.js (preferencialmente versão 14+)

Em um terminal (ou prompt de comando), criaremos uma pasta para o projeto. A partir dessa pasta, execute npm init . Isso criará alguns dos arquivos básicos do projeto Node.js de que precisamos.

Em seguida, adicionaremos a estrutura Express.js e algumas bibliotecas úteis:

 npm i express debug winston express-winston cors

Há boas razões para essas bibliotecas serem as favoritas dos desenvolvedores Node.js:

  • debug é um módulo que usaremos para evitar chamar console.log() durante o desenvolvimento de nossa aplicação. Dessa forma, podemos filtrar facilmente as instruções de depuração durante a solução de problemas. Eles também podem ser totalmente desligados na produção, em vez de serem removidos manualmente.
  • winston é responsável por registrar solicitações em nossa API e as respostas (e erros) retornadas. express-winston integra-se diretamente com o Express.js, de modo que todo o código de log winston relacionado à API padrão já está pronto.
  • cors é um middleware Express.js que nos permite habilitar o compartilhamento de recursos entre origens. Sem isso, nossa API só seria utilizável a partir de front-ends servidos exatamente do mesmo subdomínio que nosso back-end.

Nosso back-end usa esses pacotes quando está em execução. Mas também precisamos instalar algumas dependências de desenvolvimento para nossa configuração do TypeScript. Para isso, vamos executar:

 npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript

Essas dependências são necessárias para habilitar o TypeScript para o próprio código do nosso aplicativo, juntamente com os tipos usados ​​pelo Express.js e outras dependências. Isso pode economizar muito tempo quando estamos usando um IDE como WebStorm ou VSCode, permitindo concluir alguns métodos de função automaticamente durante a codificação.

As dependências finais no package.json devem ser assim:

 "dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }

Agora que temos todas as dependências necessárias instaladas, vamos começar a construir nosso próprio código!

Estrutura do projeto da API REST TypeScript

Para este tutorial, vamos criar apenas três arquivos:

  1. ./app.ts
  2. ./common/common.routes.config.ts
  3. ./users/users.routes.config.ts

A ideia por trás das duas pastas da estrutura do projeto ( common e users ) é ter módulos individuais que tenham suas próprias responsabilidades. Nesse sentido, eventualmente teremos alguns ou todos os itens a seguir para cada módulo:

  • Configuração de rota para definir as solicitações que nossa API pode manipular
  • Serviços para tarefas como conectar-se aos nossos modelos de banco de dados, fazer consultas ou conectar-se a serviços externos exigidos pela solicitação específica
  • Middleware para executar validações de solicitações específicas antes que o controlador final de uma rota lide com suas especificidades
  • Modelos para definir modelos de dados correspondentes a um determinado esquema de banco de dados, para facilitar o armazenamento e a recuperação de dados
  • Controladores para separar a configuração de rota do código que finalmente (após qualquer middleware) processa uma solicitação de rota, chama as funções de serviço acima, se necessário, e dá uma resposta ao cliente

Essa estrutura de pastas fornece um design básico de API REST, um ponto de partida inicial para o restante desta série de tutoriais e o suficiente para começar a praticar.

Um arquivo de rotas comuns no TypeScript

Na pasta common , vamos criar o arquivo common.routes.config.ts para se parecer com o seguinte:

 import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }

A maneira como estamos criando as rotas aqui é opcional. Mas como estamos trabalhando com TypeScript, nosso cenário de rotas é uma oportunidade de praticar o uso de herança com a palavra-chave extends , como veremos em breve. Neste projeto, todos os arquivos de rota têm o mesmo comportamento: eles têm um nome (que usaremos para fins de depuração) e acesso ao objeto Application Express.js principal.

Agora, podemos começar a criar o arquivo de rota dos usuários. Na pasta users , vamos criar users.routes.config.ts e começar a codificá-lo assim:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }

Aqui, estamos importando a classe CommonRoutesConfig e estendendo-a para nossa nova classe, chamada UsersRoutes . Com o construtor, enviamos o aplicativo (o objeto express.Application principal) e o nome UsersRoutes para o construtor de CommonRoutesConfig .

Este exemplo é bastante simples, mas ao dimensionar para criar vários arquivos de rota, isso nos ajudará a evitar código duplicado.

Suponha que queiramos adicionar novos recursos neste arquivo, como logging. Poderíamos adicionar o campo necessário à classe CommonRoutesConfig , e então todas as rotas que estendem CommonRoutesConfig terão acesso a ele.

Usando funções abstratas do TypeScript para funcionalidades semelhantes entre classes

E se quisermos ter alguma funcionalidade semelhante entre essas classes (como configurar os endpoints da API), mas que precise de uma implementação diferente para cada classe? Uma opção é usar um recurso TypeScript chamado abstração .

Vamos criar uma função abstrata muito simples que a classe UsersRoutes (e futuras classes de roteamento) herdará de CommonRoutesConfig . Digamos que queremos forçar todas as rotas a terem uma função (para que possamos chamá-la de nosso construtor comum) chamada configureRoutes() . É aí que vamos declarar os endpoints de cada recurso de classe de roteamento.

Para fazer isso, adicionaremos três coisas rápidas a common.routes.config.ts :

  1. A palavra-chave abstract para nossa linha de class , para habilitar a abstração para esta classe.
  2. Uma nova declaração de função no final de nossa classe, abstract configureRoutes(): express.Application; . Isso força qualquer classe que estenda CommonRoutesConfig a fornecer uma implementação que corresponda a essa assinatura — se isso não acontecer, o compilador TypeScript lançará um erro.
  3. Uma chamada para this.configureRoutes(); no final do construtor, pois agora podemos ter certeza de que essa função existirá.

O resultado:

 import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }

Com isso, qualquer classe estendendo CommonRoutesConfig deve ter uma função chamada configureRoutes() que retorne um objeto express.Application . Isso significa que users.routes.config.ts precisa ser atualizado:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }

Recapitulando o que fizemos:

Estamos importando primeiro o arquivo common.routes.config , depois o módulo express . Em seguida, definimos a classe UserRoutes , dizendo que queremos estender a classe base CommonRoutesConfig , o que implica que prometemos que ela implementará configureRoutes() .

Para enviar informações para a classe CommonRoutesConfig , estamos usando o constructor da classe. Ele espera receber o objeto express.Application , que descreveremos mais detalhadamente na próxima etapa. Com super() , passamos para o construtor de CommonRoutesConfig a aplicação e o nome de nossas rotas, que neste cenário é UsersRoutes. ( super() , por sua vez, chamará nossa implementação de configureRoutes() .)

Configurando as rotas Express.js dos endpoints de usuários

A função configureRoutes() é onde criaremos os endpoints para os usuários de nossa API REST. Lá, usaremos o aplicativo e suas funcionalidades de rota do Express.js.

A ideia de usar a função app.route() é evitar a duplicação de código, o que é fácil já que estamos criando uma API REST com recursos bem definidos. O principal recurso para este tutorial são os usuários . Temos dois casos neste cenário:

  • Quando o chamador da API deseja criar um novo usuário ou listar todos os usuários existentes, o endpoint deve inicialmente ter apenas users no final do caminho solicitado. (Não entraremos em filtragem de consulta, paginação ou outras consultas neste artigo.)
  • Quando o chamador deseja fazer algo específico para um registro de usuário específico, o caminho do recurso da solicitação seguirá o padrão users/:userId .

A maneira como .route() funciona no Express.js nos permite lidar com verbos HTTP com um encadeamento elegante. Isso ocorre porque .get() , .post() , etc., todos retornam a mesma instância do IRoute que a primeira chamada .route() faz. A configuração final ficará assim:

 configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }

O código acima permite que qualquer cliente da API REST chame nosso endpoint de users com uma solicitação POST ou GET . Da mesma forma, ele permite que um cliente chame nosso endpoint /users/:userId com uma solicitação GET , PUT , PATCH ou DELETE .

Mas para /users/:userId , também adicionamos middleware genérico usando a função all() , que será executada antes de qualquer uma das funções get() , put() , patch() ou delete() . Essa função será benéfica quando (mais tarde na série) criarmos rotas que devem ser acessadas apenas por usuários autenticados.

Você deve ter notado que em nossa função .all() — como em qualquer middleware — temos três tipos de campos: Request , Response e NextFunction .

  • A Solicitação é a forma como o Express.js representa a solicitação HTTP a ser tratada. Esse tipo atualiza e estende o tipo de solicitação nativa do Node.js.
  • A resposta também é como Express.js representa a resposta HTTP, estendendo novamente o tipo de resposta nativa do Node.js.
  • Não menos importante, o NextFunction serve como uma função de retorno de chamada, permitindo que o controle passe por quaisquer outras funções de middleware. Ao longo do caminho, todos os middlewares compartilharão os mesmos objetos de solicitação e resposta antes que o controlador finalmente envie uma resposta de volta ao solicitante.

Nosso arquivo de ponto de entrada Node.js, app.ts

Agora que configuramos alguns esqueletos básicos de rotas, começaremos a configurar o ponto de entrada do aplicativo. Vamos criar o arquivo app.ts na raiz da pasta do nosso projeto e iniciá-lo com este código:

 import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';

Apenas duas dessas importações são novas neste ponto do artigo:

  • http é um módulo nativo do Node.js. É necessário iniciar nosso aplicativo Express.js.
  • body-parser é um middleware que vem com o Express.js. Ele analisa a solicitação (no nosso caso, como JSON) antes que o controle vá para nossos próprios manipuladores de solicitação.

Agora que importamos os arquivos, vamos começar a declarar as variáveis ​​que queremos usar:

 const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');

A função express() retorna o objeto principal do aplicativo Express.js que passaremos por todo o nosso código, começando por adicioná-lo ao objeto http.Server . (Precisaremos iniciar o http.Server após configurar nosso express.Application .)

Ouviremos na porta 3000 - que o TypeScript inferirá automaticamente como um Number - em vez das portas padrão 80 (HTTP) ou 443 (HTTPS), porque normalmente seriam usadas para o front-end de um aplicativo.

Por que porta 3000?

Não há regra de que a porta deva ser 3000—se não especificada, uma porta arbitrária será atribuída—mas 3000 é usado em todos os exemplos de documentação para Node.js e Express.js, então continuamos a tradição aqui.

O Node.js pode compartilhar portas com o front-end?

Ainda podemos executar localmente em uma porta personalizada, mesmo quando queremos que nosso back-end responda a solicitações em portas padrão. Isso exigiria um proxy reverso para receber solicitações na porta 80 ou 443 com um domínio ou subdomínio específico. Em seguida, ele os redirecionaria para nossa porta interna 3000.

A matriz de routes acompanhará nossos arquivos de rotas para fins de depuração, como veremos abaixo.

Por fim, debugLog terminará como uma função semelhante a console.log , mas melhor: é mais fácil de ajustar porque tem escopo automático para o que queremos chamar de contexto de arquivo/módulo. (Neste caso, chamamos de “app” quando passamos isso em uma string para o construtor debug() .)

Agora, estamos prontos para configurar todos os nossos módulos de middleware Express.js e as rotas da nossa API:

 // here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });

O expressWinston.logger conecta-se ao Express.js, registrando automaticamente os detalhes — por meio da mesma infraestrutura do debug — para cada solicitação concluída. As opções que passamos para ele formatarão e colorirão perfeitamente a saída do terminal correspondente, com um registro mais detalhado (o padrão) quando estivermos no modo de depuração.

Observe que temos que definir nossas rotas depois de configurar expressWinston.logger .

Por último e mais importante:

 server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });

Isso realmente inicia nosso servidor. Depois de iniciado, o Node.js executará nossa função de retorno de chamada, que no modo de depuração relata os nomes de todas as rotas que configuramos - até agora, apenas UsersRoutes . Depois disso, nosso retorno de chamada nos notifica que nosso back-end está pronto para receber solicitações, mesmo quando executado em modo de produção.

Atualizando package.json para transpilar TypeScript para JavaScript e executar o aplicativo

Agora que temos nosso esqueleto pronto para ser executado, primeiro precisamos de alguma configuração padrão para habilitar a transpilação do TypeScript. Vamos adicionar o arquivo tsconfig.json na raiz do projeto:

 { "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }

Em seguida, precisamos apenas adicionar os toques finais ao package.json na forma dos seguintes scripts:

 "scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },

O script de test é um espaço reservado que substituiremos posteriormente na série.

O tsc no script start pertence ao TypeScript. Ele é responsável por transpilar nosso código TypeScript em JavaScript, que será gerado na pasta dist . Em seguida, apenas executamos a versão construída com node ./dist/app.js .

Passamos --unhandled-rejections=strict para Node.js (mesmo com Node.js v16+) porque, na prática, a depuração usando uma abordagem direta de “crash and show the stack” é mais direta do que o log mais sofisticado com um objeto expressWinston.errorLogger . Isso geralmente é verdade mesmo na produção, onde permitir que o Node.js continue em execução apesar de uma rejeição não tratada provavelmente deixará o servidor em um estado inesperado, permitindo que bugs adicionais (e mais complicados) ocorram.

O script de debug chama o script de start , mas primeiro define uma variável de ambiente DEBUG . Isso tem o efeito de habilitar todas as nossas debugLog() (mais as semelhantes do próprio Express.js, que usa o mesmo módulo de debug que usamos) para enviar detalhes úteis para o terminal - detalhes que são (convenientemente) ocultos durante a execução o servidor em modo de produção com um npm start padrão.

Tente executar npm run debug você mesmo e, depois, compare isso com npm start para ver como a saída do console muda.

Dica: Você pode limitar a saída de depuração às próprias debugLog() do nosso arquivo app.ts usando DEBUG=app em vez de DEBUG=* . O módulo de debug geralmente é bastante flexível, e esse recurso não é exceção.

Os usuários do Windows provavelmente precisarão alterar a export para SET , pois a export é como funciona no Mac e no Linux. Se seu projeto precisa dar suporte a vários ambientes de desenvolvimento, o pacote cross-env fornece uma solução direta aqui.

Testando o back-end do Live Express.js

Com npm run debug ou npm start ainda em andamento, nossa API REST estará pronta para atender solicitações na porta 3000. Neste ponto, podemos usar cURL, Postman, Insomnia, etc. para testar o back-end.

Como criamos apenas um esqueleto para o recurso de usuários, podemos simplesmente enviar solicitações sem corpo para verificar se tudo está funcionando conforme o esperado. Por exemplo:

 curl --request GET 'localhost:3000/users/12345'

Nosso back-end deve enviar de volta a resposta GET requested for id 12345 .

Quanto ao POST ing:

 curl --request POST 'localhost:3000/users' \ --data-raw ''

Este e todos os outros tipos de solicitações para os quais construímos esqueletos serão bastante semelhantes.

Preparado para o desenvolvimento rápido da API REST Node.js com TypeScript

Neste artigo, começamos a criar uma API REST configurando o projeto do zero e mergulhando nos conceitos básicos da estrutura Express.js. Em seguida, demos nosso primeiro passo para dominar o TypeScript criando um padrão com UsersRoutesConfig estendendo CommonRoutesConfig , um padrão que reutilizaremos no próximo artigo desta série. Terminamos configurando nosso ponto de entrada app.ts para usar nossas novas rotas e package.json com scripts para construir e executar nosso aplicativo.

Mas mesmo o básico de uma API REST feita com Express.js e TypeScript está bastante envolvido. Na próxima parte desta série, focamos na criação de controladores adequados para o recurso de usuários e nos aprofundamos em alguns padrões úteis para serviços, middleware, controladores e modelos.

O projeto completo está disponível no GitHub, e o código no final deste artigo é encontrado no toptal-article-01 .