Criando uma API REST Node.js/TypeScript, Parte 3: MongoDB, autenticação e testes automatizados

Publicados: 2022-03-11

Neste ponto de nossa série sobre como criar uma API REST Node.js com Express.js e TypeScript, criamos um back-end funcional e separamos nosso código em configuração de rota, serviços, middleware, controladores e modelos. Se você estiver pronto para seguir adiante, clone o repositório de exemplo e execute git checkout toptal-article-02 .

Uma API REST com Mongoose, autenticação e testes automatizados

Neste terceiro e último artigo, continuaremos desenvolvendo nossa API REST adicionando:

  • Mongoose para nos permitir trabalhar com o MongoDB e substituir nosso DAO na memória por um banco de dados real.
  • Recursos de autenticação e permissões para que os consumidores de API possam usar um JSON Web Token (JWT) para acessar nossos terminais com segurança.
  • Teste automatizado usando Mocha (uma estrutura de teste), Chai (uma biblioteca de asserção) e SuperTest (um módulo de abstração HTTP) para ajudar a verificar regressões à medida que a base de código cresce e muda.

Ao longo do caminho, adicionaremos bibliotecas de validação e segurança, ganharemos alguma experiência com o Docker e sugeriremos vários outros tópicos, bibliotecas e habilidades que os leitores fariam bem em explorar na construção e extensão de suas próprias APIs REST.

Instalando o MongoDB como um contêiner

Vamos começar substituindo nosso banco de dados na memória do artigo anterior por um real.

Para criar um banco de dados local para desenvolvimento, podemos instalar o MongoDB localmente. Mas as diferenças entre os ambientes (distribuições e versões do SO, por exemplo) podem apresentar problemas. Para evitar isso, usaremos esta oportunidade para alavancar uma ferramenta padrão do setor: o contêiner Docker.

A única coisa que os leitores precisam fazer é instalar o Docker e, em seguida, instalar o Docker Compose. Uma vez instalado, executar docker -v em um terminal deve gerar um número de versão do Docker.

Agora, para executar o MongoDB, na raiz do nosso projeto, criaremos um arquivo YAML chamado docker-compose.yml contendo o seguinte:

 version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017"

O Docker Compose nos permite executar vários contêineres ao mesmo tempo com um arquivo de configuração. No final deste artigo, veremos como executar nosso back-end da API REST no Docker também, mas por enquanto, vamos usá-lo apenas para executar o MongoDB sem precisar instalá-lo localmente:

 sudo docker-compose up -d

O comando up iniciará o contêiner definido, escutando na porta padrão do MongoDB de 27017. A opção -d desconectará o comando do terminal. Se tudo funcionar sem problemas, devemos ver uma mensagem como esta:

 Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done

Ele também criará um novo diretório de data na raiz do projeto, então devemos adicionar uma linha de data em .gitignore .

Agora, se precisarmos encerrar nosso contêiner MongoDB Docker, basta executar sudo docker-compose down e devemos ver a seguinte saída:

 Stopping toptal-rest-series_mongo_1 ... done Removing toptal-rest-series_mongo_1 ... done Removing network toptal-rest-series_default

Isso é tudo o que precisamos saber para iniciar nosso back-end da API REST Node.js/MongoDB. Vamos nos certificar de que usamos sudo docker-compose up -d para que o MongoDB esteja pronto para nosso aplicativo usar.

Usando o Mongoose para acessar o MongoDB

Para se comunicar com o MongoDB, nosso back-end utilizará uma biblioteca de modelagem de dados de objeto (ODM) chamada Mongoose. Embora o Mongoose seja bastante fácil de usar, vale a pena conferir a documentação para aprender todas as possibilidades avançadas que ele oferece para projetos do mundo real.

Para instalar o Mongoose, usamos o seguinte:

 npm i mongoose

Vamos configurar um serviço do Mongoose para gerenciar a conexão com nossa instância do MongoDB. Como esse serviço pode ser compartilhado entre vários recursos, vamos adicioná-lo à pasta common do nosso projeto.

A configuração é simples. Embora não seja estritamente necessário, teremos um objeto mongooseOptions para personalizar as seguintes opções de conexão do Mongoose:

  • useNewUrlParser : Sem isso definido como true , o Mongoose imprime um aviso de depreciação.
  • useUnifiedTopology : A documentação do Mongoose recomenda definir isso como true para usar um mecanismo de gerenciamento de conexão mais recente.
  • serverSelectionTimeoutMS : Para o propósito do UX deste projeto de demonstração, um tempo menor que o padrão de 30 segundos significa que qualquer leitor que se esqueça de iniciar o MongoDB antes do Node.js verá um feedback útil sobre ele mais cedo, em vez de um back-end aparentemente sem resposta .
  • useFindAndModify : Definir isso como false também evita um aviso de descontinuação, mas é mencionado na seção de descontinuações da documentação, em vez de entre as opções de conexão do Mongoose. Mais especificamente, isso faz com que o Mongoose use um recurso nativo mais novo do MongoDB em vez de um shim antigo do Mongoose.

Combinando essas opções com alguma lógica de inicialização e repetição, aqui está o arquivo common/services/mongoose.service.ts final:

 import mongoose from 'mongoose'; import debug from 'debug'; const log: debug.IDebugger = debug('app:mongoose-service'); class MongooseService { private count = 0; private mongooseOptions = { useNewUrlParser: true, useUnifiedTopology: true, serverSelectionTimeoutMS: 5000, useFindAndModify: false, }; constructor() { this.connectWithRetry(); } getMongoose() { return mongoose; } connectWithRetry = () => { log('Attempting MongoDB connection (will retry if needed)'); mongoose .connect('mongodb://localhost:27017/api-db', this.mongooseOptions) .then(() => { log('MongoDB is connected'); }) .catch((err) => { const retrySeconds = 5; log( `MongoDB connection unsuccessful (will retry #${++this .count} after ${retrySeconds} seconds):`, err ); setTimeout(this.connectWithRetry, retrySeconds * 1000); }); }; } export default new MongooseService();

Certifique-se de manter a diferença entre a função connect() do Mongoose e nossa própria função de serviço connectWithRetry() :

  • mongoose.connect() tenta se conectar ao nosso serviço MongoDB local (executando com docker-compose ) e expirará após milissegundos serverSelectionTimeoutMS .
  • MongooseService.connectWithRetry() repete o procedimento acima caso nosso aplicativo seja iniciado, mas o serviço MongoDB ainda não esteja em execução. Como está em um construtor singleton, connectWithRetry() será executado apenas uma vez, mas tentará novamente a chamada connect() indefinidamente, com uma pausa de segundos retrySeconds sempre que ocorrer um tempo limite.

Nosso próximo passo é substituir nosso banco de dados anterior na memória pelo MongoDB!

Removendo nosso banco de dados na memória e adicionando o MongoDB

Anteriormente, usávamos um banco de dados na memória para nos permitir focar nos outros módulos que estávamos construindo. Para usar o Mongoose, teremos que refatorar completamente users.dao.ts . Vamos precisar de mais uma instrução de import , para começar:

 import mongooseService from '../../common/services/mongoose.service';

Agora vamos remover tudo da definição da classe UsersDao , exceto o construtor. Podemos começar a preenchê-lo criando o usuário Schema for Mongoose antes do construtor:

 Schema = mongooseService.getMongoose().Schema; userSchema = new this.Schema({ _id: String, email: String, password: { type: String, select: false }, firstName: String, lastName: String, permissionFlags: Number, }, { id: false }); User = mongooseService.getMongoose().model('Users', this.userSchema);

Isso define nossa coleção do MongoDB e adiciona um recurso especial que nosso banco de dados em memória não tinha: O select: false no campo de password ocultará este campo sempre que obtivermos um usuário ou listar todos os usuários.

Nosso esquema de usuário provavelmente parece familiar porque é semelhante às nossas entidades DTO. A principal diferença é que estamos definindo quais campos devem existir em nossa coleção do MongoDB chamada Users , enquanto as entidades DTO definem quais campos aceitar em uma solicitação HTTP.

Essa parte de nossa abordagem não está mudando, portanto, ainda importando nossos três DTOs na parte superior de users.dao.ts . Mas antes de implementar nossas operações de método CRUD, atualizaremos nossos DTOs de duas maneiras.

Alteração de DTO nº 1: id vs. _id

Como o Mongoose disponibiliza automaticamente um campo _id , removeremos o campo id dos DTOs. Ele virá dos parâmetros da solicitação de rota de qualquer maneira.

Observe que os modelos do Mongoose fornecem um getter de id virtual por padrão, portanto, desabilitamos essa opção acima com { id: false } para evitar confusão. Mas isso quebrou nossa referência a user.id em nosso middleware de usuário validateSameEmailBelongToSameUser() precisamos user._id lá.

Alguns bancos de dados usam a convenção id , e outros usam _id , então não há interface perfeita. Para nosso projeto de exemplo usando o Mongoose, simplesmente prestamos atenção em qual deles estamos usando em qual ponto do código, mas a incompatibilidade ainda será exposta aos consumidores da API:

Os caminhos de cinco tipos de solicitação: 1. Uma solicitação GET não parametrizada para /users passa pelo controlador listUsers() e retorna uma matriz de objetos, cada um com uma chave _id. 2. Uma solicitação POST não parametrizada para /users passa pelo controlador createUser(), que usa um valor de ID recém-gerado, retornando-o em um objeto com uma chave id. 3. Uma solicitação não parametrizada para /auth passa pelo middleware VerifyUserPassword(), que faz uma pesquisa no MongoDB para definir req.body.userId; a partir daí, a requisição passa pelo controller createJWT(), que utiliza req.body.userId, e retorna um objeto com as chaves accessToken e refreshToken. 4. Uma solicitação não parametrizada para /auth/refresh-token passa pelo middleware validJWTNeeded(), que define res.locals.jwt.userId, e pelo middleware validRefreshNeeded(), que usa res.locals.jwt.userId e também faz um Pesquisa MongoDB para definir req.body.userId; a partir daí, o caminho passa pelo mesmo controlador e resposta do caso anterior. 5. Uma solicitação parametrizada para /users passa pela configuração de UsersRoutes, que preenche req.params.userId via Express.js, depois pelo middleware validJWTNeeded(), que define res.locals.jwt.userId, e outras funções de middleware (que usam req. params.userId, res.locals.jwt.userId ou ambos; e/ou faça uma pesquisa no MongoDB e use result._id) e, finalmente, por meio de uma função UsersController que usará req.body.id e retornará nenhum corpo ou um objeto com uma chave _id.
O uso e a exposição de IDs de usuário em todo o projeto final da API REST. Observe que as várias convenções internas implicam em diferentes fontes de dados de ID do usuário: um parâmetro de solicitação direta, dados codificados por JWT ou um registro de banco de dados recém-obtido.

Deixamos como exercício para os leitores implementar uma das muitas soluções do mundo real disponíveis no final do projeto.

Alteração DTO nº 2: Preparando-se para permissões baseadas em sinalizadores

Também renomearemos permissionLevel para permissionFlags nos DTOs para refletir o sistema de permissões mais sofisticado que implementaremos, bem como a definição userSchema do Mongoose acima.

DTOs: E o Princípio DRY?

Lembre-se, o DTO contém apenas os campos que queremos passar entre o cliente da API e nosso banco de dados. Isso pode parecer lamentável porque há alguma sobreposição entre o modelo e os DTOs, mas tome cuidado para não forçar demais o DRY ao custo da “segurança por padrão”. Se a adição de um campo exigir apenas sua adição em um local, os desenvolvedores podem expô-lo involuntariamente na API quando ele deveria ser apenas interno. Isso porque o processo não os força a pensar no armazenamento e na transferência de dados como dois contextos separados com dois conjuntos de requisitos potencialmente diferentes.

Com nossas alterações de DTO feitas, podemos implementar nossas operações de método CRUD (após o construtor UsersDao ), começando com create :

 async addUser(userFields: CreateUserDto) { const userId = shortid.generate(); const user = new this.User({ _id: userId, ...userFields, permissionFlags: 1, }); await user.save(); return userId; }

Observe que o que quer que o consumidor da API envie para permissionFlags por meio de userFields , nós o substituiremos pelo valor 1 .

Em seguida, lemos , a funcionalidade básica para obter um usuário por ID, obter um usuário por e-mail e listar usuários com paginação:

 async getUserByEmail(email: string) { return this.User.findOne({ email: email }).exec(); } async getUserById(userId: string) { return this.User.findOne({ _id: userId }).populate('User').exec(); } async getUsers(limit = 25, page = 0) { return this.User.find() .limit(limit) .skip(limit * page) .exec(); }

Para atualizar um usuário, uma única função DAO será suficiente porque a função findOneAndUpdate() do Mongoose subjacente pode atualizar o documento inteiro ou apenas parte dele. Observe que nossa própria função levará userFields como PatchUserDto ou PutUserDto , usando um tipo de união TypeScript (significado por | ):

 async updateUserById( userId: string, userFields: PatchUserDto | PutUserDto ) { const existingUser = await this.User.findOneAndUpdate( { _id: userId }, { $set: userFields }, { new: true } ).exec(); return existingUser; }

A opção new: true diz ao Mongoose para retornar o objeto como está após a atualização, em vez de como estava originalmente.

A exclusão é concisa com o Mongoose:

 async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); }

Os leitores podem notar que cada uma das chamadas para funções-membro User está encadeada a uma chamada exec() . Isso é opcional, mas os desenvolvedores do Mongoose o recomendam porque fornece melhores rastreamentos de pilha durante a depuração.

Depois de codificar nosso DAO, precisamos atualizar ligeiramente nossos users.service.ts de nosso último artigo para corresponder às novas funções. Não há necessidade de grandes refatorações, apenas três retoques:

 @@ -16,3 +16,3 @@ class UsersService implements CRUD { async list(limit: number, page: number) { - return UsersDao.getUsers(); + return UsersDao.getUsers(limit, page); } @@ -20,3 +20,3 @@ class UsersService implements CRUD { async patchById(id: string, resource: PatchUserDto): Promise<any> { - return UsersDao.patchUserById(id, resource); + return UsersDao.updateUserById(id, resource); } @@ -24,3 +24,3 @@ class UsersService implements CRUD { async putById(id: string, resource: PutUserDto): Promise<any> { - return UsersDao.putUserById(id, resource); + return UsersDao.updateUserById(id, resource); }

A maioria das chamadas de função permanecem exatamente as mesmas, pois quando refatoramos o UsersDao , mantivemos a estrutura que criamos no artigo anterior. Mas por que as exceções?

  • Estamos usando updateUserById() para PUT e PATCH como sugerimos acima. (Como mencionado na Parte 2, estamos seguindo implementações típicas de API REST em vez de tentar aderir a RFCs específicas ao pé da letra. Entre outras coisas, isso significa não ter solicitações PUT criando novas entidades se elas não existirem; dessa forma, nosso back-end não entrega o controle da geração de ID para os consumidores da API.)
  • Estamos passando os parâmetros limit e page para getUsers() já que nossa nova implementação do DAO fará uso deles.

A estrutura principal aqui é um padrão bastante robusto. Por exemplo, ele pode ser reutilizado se os desenvolvedores quiserem trocar Mongoose e MongoDB por algo como TypeORM e PostgreSQL. Como acima, tal substituição exigiria simplesmente a refatoração das funções individuais do DAO, mantendo suas assinaturas para corresponder ao restante do código.

Testando nossa API REST apoiada pelo Mongoose

Vamos iniciar o back-end da API com npm start . Em seguida, tentaremos criar um usuário:

 curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'

O objeto de resposta contém um novo ID do usuário:

 { "id": "7WYQoVZ3E" }

Assim como no artigo anterior, os demais testes manuais serão mais fáceis usando variáveis ​​de ambiente:

 REST_API_EXAMPLE_

A atualização do usuário fica assim:

 curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }'

A resposta deve começar com HTTP/1.1 204 No Content . (Sem a opção --include , nenhuma resposta seria impressa, o que está de acordo com nossa implementação.)

Se agora conseguirmos que o usuário verifique as atualizações acima…:

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }'

… a resposta mostra os campos esperados, incluindo o campo _id discutido acima:

 { "_id": "7WYQoVZ3E", "email": "[email protected]", "permissionFlags": 1, "__v": 0, "firstName": "Marcos", "lastName": "Silva" }

Há também um campo especial, __v , usado pelo Mongoose para versionamento; ele será incrementado cada vez que este registro for atualizado.

Em seguida, vamos listar os usuários:

 curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

A resposta esperada é a mesma, apenas envolvida em [] .

Agora que nossa senha está armazenada com segurança, vamos nos certificar de que podemos remover o usuário:

 curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'

Esperamos uma resposta 204 novamente.

Os leitores podem se perguntar se o campo de senha funcionou corretamente, já que nosso select: false na definição do Mongoose Schema o escondeu de nossa saída GET conforme pretendido. Vamos repetir nosso POST inicial para criar um usuário novamente e depois verificar. (Não se esqueça de armazenar o novo ID para mais tarde.)

Senhas ocultas e depuração direta de dados com contêineres MongoDB

Para verificar se as senhas estão armazenadas com segurança (ou seja, com hash, em vez de em texto simples), os desenvolvedores podem inspecionar os dados do MongoDB diretamente. Uma maneira é acessar o cliente mongo CLI padrão de dentro do contêiner do Docker em execução:

 sudo docker exec -it toptal-rest-series_mongo_1 mongo

A partir daí, executar use api-db seguido por db.users.find().pretty() listará todos os dados do usuário, incluindo senhas.

Aqueles que preferem uma GUI podem instalar um cliente MongoDB separado como o Robo 3T:

Uma barra lateral esquerda mostra conexões de banco de dados, cada uma contendo uma hierarquia de coisas como bancos de dados, funções e usuários. O painel principal tem guias para executar consultas. A guia atual está conectada ao banco de dados api-db de localhost:27017 com a consulta "db.getCollection('users').find({})" com um resultado. O resultado tem quatro campos: _id, password, email e __v. O campo de senha começa com "$argon2$i$v=19$m=4096,t=3,p=1$" e termina com sal e hash, separados por um cifrão e codificado em base 64.
Examinando dados do MongoDB diretamente usando o Robo 3T.

O prefixo de senha ( $argon2... ) faz parte do formato de string PHC e é armazenado intencionalmente sem modificações: O fato de Argon2 e seus parâmetros gerais serem mencionados não ajudaria um hacker a determinar as senhas originais se eles conseguissem roubar o base de dados. A senha armazenada pode ser reforçada ainda mais usando salting, uma técnica que usaremos abaixo com JWTs. Deixamos como exercício para o leitor aplicar salga acima e examinar a diferença entre os valores armazenados quando dois usuários inserem a mesma senha.

Agora sabemos que o Mongoose envia dados com sucesso para nosso banco de dados MongoDB. Mas como sabemos que nossos consumidores de API enviarão dados apropriados em suas solicitações para nossas rotas de usuário?

Adicionando validador expresso

Existem várias maneiras de realizar a validação de campo. Neste artigo, usaremos o validador expresso, que é bastante estável, fácil de usar e documentado decentemente. Embora possamos usar a funcionalidade de validação que vem com o Mongoose, o express-validator fornece recursos extras. Por exemplo, ele vem com um validador pronto para uso para endereços de e-mail, que no Mongoose exigiria que codifiquemos um validador personalizado.

Vamos instalá-lo:

 npm i express-validator

Para definir os campos que queremos validar, usaremos o método body() que importaremos em nossos users.routes.config.ts . O método body() validará os campos e gerará uma lista de erros—armazenada no objeto express.Request —em caso de falha.

Precisamos então de nosso próprio middleware para verificar e fazer uso da lista de erros. Como essa lógica provavelmente funcionará da mesma maneira para rotas diferentes, vamos criar common/middleware/body.validation.middleware.ts com o seguinte:

 import express from 'express'; import { validationResult } from 'express-validator'; class BodyValidationMiddleware { verifyBodyFieldsErrors( req: express.Request, res: express.Response, next: express.NextFunction ) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).send({ errors: errors.array() }); } next(); } } export default new BodyValidationMiddleware();

Com isso, estamos prontos para lidar com qualquer erro gerado pela função body() . Vamos adicionar o seguinte de volta em users.routes.config.ts :

 import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator';

Agora podemos atualizar nossas rotas com o seguinte:

 @@ -15,3 +17,6 @@ export class UsersRoutes extends CommonRoutesConfig { .post( - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailDoesntExist, @@ -28,3 +33,10 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.put(`/users/:userId`, [ - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + body('firstName').isString(), + body('lastName').isString(), + body('permissionFlags').isInt(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailBelongToSameUser, @@ -34,2 +46,11 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.patch(`/users/:userId`, [ + body('email').isEmail().optional(), + body('password') + .isLength({ min: 5 }) + .withMessage('Password must be 5+ characters') + .optional(), + body('firstName').isString().optional(), + body('lastName').isString().optional(), + body('permissionFlags').isInt().optional(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validatePatchEmail,

Certifique-se de adicionar BodyValidationMiddleware.verifyBodyFieldsErrors em cada rota após qualquer linha body() que esteja presente, caso contrário, nenhuma delas terá efeito.

Observe como atualizamos nossas rotas POST e PUT para usar o validateRequiredUserBodyFields expresso em vez de nossa função validRequiredUserBodyFields criada internamente. Como essas rotas eram as únicas que usavam essa função, sua implementação pode ser excluída de users.middleware.ts .

É isso! Os leitores podem reiniciar o Node.js e experimentar o resultado usando seus clientes REST favoritos para ver como ele lida com várias entradas. Não se esqueça de explorar a documentação do validador expresso para mais possibilidades; nosso exemplo é apenas um ponto de partida para validação de solicitação.

Dados válidos são um aspecto a ser garantido; usuários e ações válidos são outro.

Fluxo de Autenticação x Permissões (ou "Autorização")

Nosso aplicativo Node.js expõe um conjunto completo de users/ pontos de extremidade, permitindo que os consumidores de API criem, atualizem e listem usuários. Mas cada endpoint permite acesso público ilimitado. É um padrão comum impedir que os usuários alterem os dados uns dos outros e que pessoas de fora acessem qualquer endpoint que não queremos que seja público.

Existem dois aspectos principais envolvidos nessas restrições e ambos encurtam para “auth”. A autenticação é sobre de quem é a solicitação e a autorização é sobre se eles têm permissão para fazer o que estão solicitando. É importante ficar ciente de qual deles está sendo discutido. Mesmo sem formulários curtos, os códigos de resposta HTTP padrão conseguem confundir o problema: 401 Unauthorized é sobre autenticação e 403 Forbidden é sobre autorização. Vamos errar do lado de “auth” que significa “autenticação” em nomes de módulos e usar “permissões” para questões de autorização.

Mesmo sem formulários curtos, os códigos de resposta HTTP padrão conseguem confundir o problema: 401 Unauthorized é sobre autenticação e 403 Forbidden é sobre autorização.

Tweet

Há muitas abordagens de autenticação a serem exploradas, incluindo provedores de identidade terceirizados como Auth0. Neste artigo, escolhemos uma implementação básica, mas escalável. É baseado em JWTs.

Um JWT consiste em JSON criptografado com alguns metadados não relacionados à autenticação, que no nosso caso incluem o endereço de e-mail do usuário e os sinalizadores de permissão. O JSON também conterá um segredo para verificar a integridade dos metadados.

A ideia é exigir que os clientes enviem um JWT válido dentro de cada solicitação não pública. Isso nos permite verificar se o cliente tinha credenciais válidas recentemente para o endpoint que deseja usar, sem precisar enviar as próprias credenciais pela rede com cada solicitação.

Mas onde isso se encaixará em nossa base de código de API de exemplo? Fácil: com middleware podemos usar em nossa configuração de rotas!

Adicionando o Módulo de Autenticação

Vamos primeiro configurar o que estará em nossos JWTs. Aqui é onde começaremos a usar o campo permissionFlags de nosso recurso de usuário, mas apenas porque são metadados convenientes para criptografar dentro de JWTs - não porque os JWTs inerentemente tenham algo a ver com lógica de permissões refinadas.

Antes de criar um middleware gerador de JWT, precisaremos adicionar uma função especial a users.dao.ts para recuperar o campo de senha, pois configuramos o Mongoose para evitar recuperá-lo normalmente:

 async getUserByEmailWithPassword(email: string) { return this.User.findOne({ email: email }) .select('_id email permissionFlags +password') .exec(); }

E no users.service.ts :

 async getUserByEmailWithPassword(email: string) { return UsersDao.getUserByEmailWithPassword(email); }

Agora, vamos criar uma pasta de auth na raiz do nosso projeto — adicionaremos um endpoint para permitir que os consumidores de API gerem JWTs. Primeiro, vamos criar um middleware para ele em auth/middleware/auth.middleware.ts , como um singleton chamado AuthMiddleware .

Vamos precisar de alguns import s:

 import express from 'express'; import usersService from '../../users/services/users.service'; import * as argon2 from 'argon2';

Na classe AuthMiddleware , criaremos uma função de middleware para verificar se um usuário da API incluiu credenciais de login válidas em sua solicitação:

 async verifyUserPassword( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( req.body.email ); if (user) { const passwordHash = user.password; if (await argon2.verify(passwordHash, req.body.password)) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } } // Giving the same message in both cases // helps protect against cracking attempts: res.status(400).send({ errors: ['Invalid email and/or password'] }); }

Quanto ao middleware para garantir que o email e a password existam em req.body , usaremos o express-validator quando configurarmos a rota para usar a função verifyUserPassword() acima.

Armazenando segredos do JWT

Para gerar um JWT, precisaremos de um segredo JWT, que usaremos para assinar nossos JWTs gerados e também para validar JWTs recebidos de solicitações de clientes. Em vez de codificar o valor do segredo JWT em um arquivo TypeScript, vamos armazená-lo em um arquivo separado de “variável de ambiente”, .env , que nunca deve ser enviado para um repositório de código .

Como é prática comum, adicionamos um arquivo .env.example ao repositório para ajudar os desenvolvedores a entender quais variáveis ​​são necessárias ao criar o .env real. No nosso caso, queremos uma variável chamada JWT_SECRET armazenando nosso segredo JWT como uma string. Os leitores que esperarem até o final deste artigo e usarem a ramificação final do repositório precisarão se lembrar de alterar esses valores localmente .

Projetos do mundo real precisarão seguir especialmente as melhores práticas do JWT, diferenciando os segredos do JWT de acordo com o ambiente (dev, staging, produção etc.).

Nosso arquivo .env (na raiz do projeto) deve usar o seguinte formato, mas não deve manter o mesmo valor secreto:

 JWT_SECRET=My!@!Se3cr8tH4sh3

Uma maneira fácil de carregar essas variáveis ​​em nosso aplicativo é usar uma biblioteca chamada dotenv:

 npm i dotenv

A única configuração necessária é chamar a função dotenv.config() assim que iniciarmos nosso aplicativo. No topo de app.ts , adicionaremos:

 import dotenv from 'dotenv'; const dotenvResult = dotenv.config(); if (dotenvResult.error) { throw dotenvResult.error; }

O controlador de autenticação

O último pré-requisito de geração de JWT é instalar a biblioteca jsonwebtoken e seus tipos TypeScript:

 npm i jsonwebtoken npm i --save-dev @types/jsonwebtoken

Agora, vamos criar o controlador /auth em auth/controllers/auth.controller.ts . Não precisamos importar a biblioteca dotenv aqui porque importá-la em app.ts disponibiliza o conteúdo do arquivo .env em todo o aplicativo por meio do objeto global Node.js chamado process :

 import express from 'express'; import debug from 'debug'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; const log: debug.IDebugger = debug('app:auth-controller'); /** * This value is automatically populated from .env, a file which you will have * to create for yourself at the root of the project. * * See .env.example in the repo for the required format. */ // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; const tokenExpirationInSeconds = 36000; class AuthController { async createJWT(req: express.Request, res: express.Response) { try { const refreshId = req.body.userId + jwtSecret; const salt = crypto.createSecretKey(crypto.randomBytes(16)); const hash = crypto .createHmac('sha512', salt) .update(refreshId) .digest('base64'); req.body.refreshKey = salt.export(); const token = jwt.sign(req.body, jwtSecret, { expiresIn: tokenExpirationInSeconds, }); return res .status(201) .send({ accessToken: token, refreshToken: hash }); } catch (err) { log('createJWT error: %O', err); return res.status(500).send(); } } } export default new AuthController();

A biblioteca jsonwebtoken assinará um novo token com nosso jwtSecret . Também geraremos um sal e um hash usando o módulo de crypto nativo do Node.js e, em seguida, os usaremos para criar um refreshToken com o qual os consumidores da API podem atualizar o JWT atual - uma configuração que é particularmente boa para um aplicativo para poder escalar.

Qual é a diferença entre refreshKey , refreshToken e accessToken ? Os *Token s são enviados para nossos consumidores de API com a ideia de que o accessToken é usado para qualquer solicitação além do que está disponível para o público em geral, e refreshToken é usado para solicitar a substituição de um accessToken expirado. A refreshKey , por outro lado, é usada para passar a variável salt — criptografada dentro do refreshToken — de volta ao nosso middleware de atualização, que veremos a seguir.

Observe que nossa implementação tem a expiração do token do identificador jsonwebtoken para nós. Se o JWT estiver expirado, o cliente precisará se autenticar novamente.

Rota inicial de autenticação da API REST Node.js

Vamos configurar o endpoint agora em auth/auth.routes.config.ts :

 import { CommonRoutesConfig } from '../common/common.routes.config'; import authController from './controllers/auth.controller'; import authMiddleware from './middleware/auth.middleware'; import express from 'express'; import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator'; export class AuthRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'AuthRoutes'); } configureRoutes(): express.Application { this.app.post(`/auth`, [ body('email').isEmail(), body('password').isString(), BodyValidationMiddleware.verifyBodyFieldsErrors, authMiddleware.verifyUserPassword, authController.createJWT, ]); return this.app; } }

E não se esqueça de adicioná-lo ao nosso arquivo app.ts :

 // ... import { AuthRoutes } from './auth/auth.routes.config'; // ... routes.push(new AuthRoutes(app)); // independent: can go before or after UsersRoute // ...

Estamos prontos para reiniciar o Node.js e testar agora, certificando-se de corresponder às credenciais que usamos para criar nosso usuário de teste anteriormente:

 curl --request POST 'localhost:3000/auth' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'

A resposta será algo como:

 { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8", "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ==" }

Como antes, vamos definir algumas variáveis ​​de ambiente por conveniência usando os valores acima:

 REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"

Excelente! Temos nosso token de acesso e um token de atualização, mas precisamos de algum middleware que possa fazer algo útil com eles.

JWT Middleware

Precisaremos de um novo tipo TypeScript para manipular a estrutura JWT em sua forma decodificada. Crie common/types/jwt.ts com isso nele:

 export type Jwt = { refreshKey: string; userId: string; permissionFlags: string; };

Vamos implementar funções de middleware para verificar a presença de um token de atualização, para verificar um token de atualização e para verificar um JWT. Todos os três podem ir em um novo arquivo, auth/middleware/jwt.middleware.ts :

 import express from 'express'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { Jwt } from '../../common/types/jwt'; import usersService from '../../users/services/users.service'; // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; class JwtMiddleware { verifyRefreshBodyField( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.refreshToken) { return next(); } else { return res .status(400) .send({ errors: ['Missing required field: refreshToken'] }); } } async validRefreshNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( res.locals.jwt.email ); const salt = crypto.createSecretKey( Buffer.from(res.locals.jwt.refreshKey.data) ); const hash = crypto .createHmac('sha512', salt) .update(res.locals.jwt.userId + jwtSecret) .digest('base64'); if (hash === req.body.refreshToken) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } else { return res.status(400).send({ errors: ['Invalid refresh token'] }); } } validJWTNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.headers['authorization']) { try { const authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { res.locals.jwt = jwt.verify( authorization[1], jwtSecret ) as Jwt; next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } } } export default new JwtMiddleware();

The validRefreshNeeded() function also verifies if the refresh token is correct for a specific user ID. If it is, then below we'll reuse authController.createJWT to generate a new JWT for the user.

We also have validJWTNeeded() , which validates whether the API consumer sent a valid JWT in the HTTP headers respecting the convention Authorization: Bearer <token> . (Yes, that's another unfortunate “auth” conflation.)

Now to configure a new route for refreshing the token and the permission flags encoded within it.

JWT Refresh Route

In auth.routes.config.ts we'll import our new middleware:

 import jwtMiddleware from './middleware/jwt.middleware';

Then we'll add the following route:

 this.app.post(`/auth/refresh-token`, [ jwtMiddleware.validJWTNeeded, jwtMiddleware.verifyRefreshBodyField, jwtMiddleware.validRefreshNeeded, authController.createJWT, ]);

Now we can test if it is working properly with the accessToken and refreshToken we received earlier:

 curl --request POST 'localhost:3000/auth/refresh-token' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw "{ \"refreshToken\": \"$REST_API_EXAMPLE_REFRESH\" }"

We should expect to receive a new accessToken and a new refreshToken to be used later. We leave it as an exercise for the reader to ensure that the back end invalidates previous tokens and limits how often new ones can be requested.

Now our API consumers are able to create, validate, and refresh JWTs. Let's look at some permissions concepts, then implement one and integrate it with our JWT middleware in our user routes.

Permissões do usuário

Once we know who an API client is, we want to know whether they're allowed to use the resource they're requesting. It's quite common to manage combinations of permissions for each user. Without adding much complexity, this allows for more flexibility than a traditional “access level” strategy. Regardless of the business logic we use for each permission, it's quite straightforward to create a generic way to handle it.

Bitwise AND ( & ) and Powers of Two

To manage permissions, we'll leverage JavaScript's built-in bitwise AND operator, & . This approach lets us store a whole set of permissions information as a single, per-user number, with each of its binary digits representing whether the user has permission to do something. But there's no need to worry about the math behind it too much—the point is that it's easy to use.

All we need to do is define each kind of permission (a permission flag ) as a power of 2 (1, 2, 4, 8, 16, 32, …). Then we can attach business logic to each flag, up to a maximum of 31 flags. For example, an audio-accessible, international blog might have these permissions:

  • 1: Authors can edit text.
  • 2: Illustrators can replace illustrations.
  • 4: Narrators can replace the audio file corresponding to any paragraph.
  • 8: Translators can edit translations.

This approach allows for all sorts of permission flag combinations for users:

  • An author's (or editor's) permission flags value will be just the number 1.
  • An illustrator's permission flags will be the number 2. But some authors are also illustrators. In that case, we sum the relevant permissions values: 1 + 2 = 3.
  • A narrator's flags will be 4. In the case of an author who narrates their own work, it will be 1 + 4 = 5. If they also illustrate, it's 1 + 2 + 4 = 7.
  • A translator will have a permission value of 8. Multilingual authors would then have flags of 1 + 8 = 9. A translator who also narrates (but is not an author) would have 4 + 8 = 12.
  • If we want to have a sudo admin, having all combined permissions, we can simply use 2,147,483,647, which is the maximum safe value for a 32-bit integer.

Readers can test this logic as plain JavaScript:

  • User with permission 5 trying to edit text (permission flag 1):

Input: 5 & 1

Output: 1

  • User with permission 1 trying to narrate (permission flag 4):

Input: 1 & 4

Output: 0

  • User with permission 12 trying to narrate:

Input: 12 & 4

Output: 4

When the output is 0, we block the user; otherwise, we let them access what they are trying to access.

Permission Flag Implementation

We'll store permissions flags inside the common folder since the business logic can be shared with future modules. Let's start by adding an enum to hold some permission flags at common/middleware/common.permissionflag.enum.ts :

 export enum PermissionFlag { FREE_PERMISSION = 1, PAID_PERMISSION = 2, ANOTHER_PAID_PERMISSION = 4, ADMIN_PERMISSION = 8, ALL_PERMISSIONS = 2147483647, }

Note: Since this is an example project, we kept the flag names fairly generic.

Before we forget, now's a good time for a quick return to the addUser() function in our user DAO to replace our temporary magic number 1 with PermissionFlag.FREE_PERMISSION . We'll also need a corresponding import statement.

We can also import it into a new middleware file at common/middleware/common.permission.middleware.ts with a singleton class named CommonPermissionMiddleware :

 import express from 'express'; import { PermissionFlag } from './common.permissionflag.enum'; import debug from 'debug'; const log: debug.IDebugger = debug('app:common-permission-middleware');

Instead of creating several similar middleware functions, we'll use the factory pattern to create a special factory method (or factory function or simply factory ). Our factory function will allow us to generate—at the time of route configuration—middleware functions to check for any permission flag needed. With that, we avoid having to manually duplicate our middleware function whenever we add a new permission flag.

Here's the factory that will generate a middleware function that checks for whatever permission flag we pass it:

permissionFlagRequired(requiredPermissionFlag: PermissionFlag) { return ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { const userPermissionFlags = parseInt( res.locals.jwt.permissionFlags ); if (userPermissionFlags & requiredPermissionFlag) { next(); } else { res.status(403).send(); } } catch (e) { log(e); } }; }

Um caso mais personalizado é que os únicos usuários que devem poder acessar um registro de usuário específico são o mesmo usuário ou um administrador:

 async onlySameUserOrAdminCanDoThisAction( req: express.Request, res: express.Response, next: express.NextFunction ) { const userPermissionFlags = parseInt(res.locals.jwt.permissionFlags); if ( req.params && req.params.userId && req.params.userId === res.locals.jwt.userId ) { return next(); } else { if (userPermissionFlags & PermissionFlag.ADMIN_PERMISSION) { return next(); } else { return res.status(403).send(); } } }

Adicionaremos uma última peça de middleware, desta vez em users.middleware.ts :

 async userCantChangePermission( req: express.Request, res: express.Response, next: express.NextFunction ) { if ( 'permissionFlags' in req.body && req.body.permissionFlags !== res.locals.user.permissionFlags ) { res.status(400).send({ errors: ['User cannot change permission flags'], }); } else { next(); } }

E como a função acima depende de res.locals.user , podemos preencher esse valor em validateUserExists() antes da chamada next() :

 // ... if (user) { res.locals.user = user; next(); } else { // ...

Na verdade, fazer isso em validateUserExists() o tornará desnecessário em validateSameEmailBelongToSameUser() . Podemos eliminar nossa chamada de banco de dados lá, substituindo-a pelo valor que podemos contar com o cache em res.locals :

 - const user = await userService.getUserByEmail(req.body.email); - if (user && user.id === req.params.userId) { + if (res.locals.user._id === req.params.userId) {

Agora estamos prontos para integrar nossa lógica de permissões em users.routes.config.ts .

Exigindo permissões

Primeiro, importaremos nosso novo middleware e enum :

 import jwtMiddleware from '../auth/middleware/jwt.middleware'; import permissionMiddleware from '../common/middleware/common.permission.middleware'; import { PermissionFlag } from '../common/middleware/common.permissionflag.enum';

Queremos que a lista de usuários seja acessível apenas por solicitações feitas por alguém com permissões de administrador, mas ainda queremos que a capacidade de criar um novo usuário seja pública, conforme o fluxo normal de expectativas de UX. Vamos restringir a lista de usuários primeiro usando nossa função de fábrica antes do nosso controlador:

 this.app .route(`/users`) .get( jwtMiddleware.validJWTNeeded, permissionMiddleware.permissionFlagRequired( PermissionFlag.ADMIN_PERMISSION ), UsersController.listUsers ) // ...

Lembre-se de que a chamada de fábrica aqui ( (...) ) retorna uma função de middleware — portanto, todo o middleware normal, não-fábrica, é referenciado sem invocação ( () ).

Outra restrição comum é que, para todas as rotas que incluem um userId , queremos que apenas esse mesmo usuário ou administrador tenha acesso:

 .route(`/users/:userId`) - .all(UsersMiddleware.validateUserExists) + .all( + UsersMiddleware.validateUserExists, + jwtMiddleware.validJWTNeeded, + permissionMiddleware.onlySameUserOrAdminCanDoThisAction + ) .get(UsersController.getUserById)

Também evitaremos que os usuários escalem seus privilégios adicionando UsersMiddleware.userCantChangePermission , logo antes da referência de função UsersController no final de cada uma das rotas PUT e PATCH .

Mas vamos supor ainda que nossa lógica de negócios da API REST permite que apenas usuários com PAID_PERMISSION atualizem suas informações. Isso pode ou não estar alinhado com as necessidades de negócios de outros projetos: é apenas para testar a diferença entre permissão paga e gratuita.

Isso pode ser feito adicionando outra chamada de gerador após cada uma das referências userCantChangePermission que acabamos de adicionar:

 permissionMiddleware.permissionFlagRequired( PermissionFlag.PAID_PERMISSION ),

Com isso, estamos prontos para reiniciar o Node.js e testá-lo.

Teste de permissões manuais

Para testar as rotas, vamos tentar GET a lista de usuários sem um token de acesso:

 curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

Recebemos uma resposta HTTP 401 porque precisamos usar um JWT válido. Vamos tentar com um token de acesso de nossa autenticação anterior:

 curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

Desta vez obtemos um HTTP 403. Nosso token é válido, mas estamos proibidos de usar este endpoint porque não temos ADMIN_PERMISSION .

No entanto, não devemos precisar dele para GET nosso próprio registro de usuário:

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

A resposta:

 { "_id": "UdgsQ0X1w", "email": "[email protected]", "permissionFlags": 1, "__v": 0 }

Por outro lado, tentar atualizar nosso próprio registro de usuário deve falhar, pois nosso valor de permissão é 1 (somente FREE_PERMISSION ):

 curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw '{ "firstName": "Marcos" }'

A resposta é 403, como esperado.

Como exercício de leitura, recomendo alterar o permissionFlags do usuário no banco de dados local e fazer uma nova postagem para /auth (para gerar um token com o novo permissionFlags ), e tentar PATCH no usuário novamente. Lembre-se de que você precisará definir os sinalizadores para o valor numérico de PAID_PERMISSION ou ALL_PERMISSIONS , pois nossa lógica de negócios especifica que ADMIN_PERMISSION por si só não permite que você corrija outros usuários ou até mesmo a si mesmo.

O requisito de uma nova postagem para /auth traz um cenário de segurança que vale a pena ter em mente. Quando o proprietário de um site altera as permissões de um usuário, por exemplo, para tentar bloquear um usuário que se comporta mal, o usuário não verá isso entrar em vigor até a próxima atualização do JWT. Isso ocorre porque a verificação de permissões usa os próprios dados do JWT para evitar um acerto extra do banco de dados.

Serviços como Auth0 podem ajudar oferecendo rotação automática de token, mas os usuários ainda terão um comportamento inesperado do aplicativo durante o tempo entre as rotações, por mais curto que seja normalmente. Para mitigar isso, os desenvolvedores devem tomar cuidado para revogar ativamente os tokens de atualização em resposta às alterações de permissões.


Ao trabalhar em uma API REST, os desenvolvedores podem se proteger contra possíveis bugs executando novamente uma pilha de comandos cURL periodicamente. Mas isso é lento e propenso a erros, e rapidamente se torna tedioso.

Testes Automatizados

À medida que uma API cresce, torna-se difícil manter a qualidade do software, especialmente com a lógica de negócios que muda frequentemente. Para reduzir ao máximo os bugs da API e implantar novas alterações com confiança, é muito comum ter um conjunto de testes para o front-end e/ou back-end de um aplicativo.

Em vez de mergulhar na escrita de testes e código testável, mostraremos algumas mecânicas básicas e forneceremos um conjunto de testes funcional para os leitores construírem.

Lidando com sobras de dados de teste

Antes de automatizar, vale a pena pensar no que acontece com os dados de teste.

Estamos usando o Docker Compose para executar nosso banco de dados local, esperando usar esse banco de dados para desenvolvimento, não como uma fonte de dados de produção ao vivo. Os testes que executaremos aqui afetarão o banco de dados local, deixando um novo conjunto de dados de teste para trás toda vez que os executarmos. Isso não deve ser um problema na maioria dos casos, mas se for, deixamos aos leitores o exercício de alterar o docker-compose.yml para criar um novo banco de dados para fins de teste.

No mundo real, os desenvolvedores geralmente executam testes automatizados como parte de um pipeline de integração contínua. Para fazer isso, faria sentido configurar – no nível do pipeline – uma maneira de criar um banco de dados temporário para cada execução de teste.

Usaremos Mocha, Chai e SuperTest para criar nossos testes:

 npm i --save-dev chai mocha supertest @types/chai @types/express @types/mocha @types/supertest ts-node

O Mocha gerenciará nosso aplicativo e executará os testes, o Chai permitirá uma expressão de teste mais legível e o SuperTest facilitará o teste de ponta a ponta (E2E) chamando nossa API como um cliente REST faria.

Precisaremos atualizar nossos scripts em package.json :

 "scripts": { // ... "test": "mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict", "test-debug": "export DEBUG=* && npm test" },

Isso nos permitirá executar testes em uma pasta que criaremos, chamada test .

Um meta-teste

Para testar nossa infraestrutura de testes, vamos criar um arquivo, test/app.test.ts :

 import { expect } from 'chai'; describe('Index Test', function () { it('should always pass', function () { expect(true).to.equal(true); }); });

A sintaxe aqui pode parecer incomum, mas está correta. Nós definimos os testes por comportamento expect() dentro dos blocos it() — que significa o corpo de uma função que passaremos para it() — que são chamados dentro dos blocos describe() .

Agora, no terminal, vamos executar:

 npm run test

Devemos ver isso:

 > mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict Index Test ✓ should always pass 1 passing (6ms)

Excelente! Nossas bibliotecas de teste estão instaladas e prontas para uso.

Simplificação dos testes

Para manter a saída de teste limpa, desejaremos silenciar totalmente o log de solicitações Winston durante as execuções de teste normais. Isso é tão fácil quanto uma mudança rápida em nossa ramificação else não depurada em app.ts para detectar se a função it() do Mocha está presente:

 if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, make terse + if (typeof global.it === 'function') { + loggerOptions.level = 'http'; // for non-debug test runs, squelch entirely + } }

Um toque final que precisamos adicionar é exportar nosso app.ts para ser consumido por nossos testes. No final de app.ts , adicionaremos export default logo antes de server.listen() , porque listen() retorna nosso objeto http.Server

Com um teste rápido npm run test para verificar se não quebramos a pilha, agora estamos prontos para testar nossa API.

Nosso primeiro teste automatizado de API REST real

Para começar a configurar nossos testes de usuários, vamos criar test/users/users.test.ts , começando com as importações e variáveis ​​de teste necessárias:

 import app from '../../app'; import supertest from 'supertest'; import { expect } from 'chai'; import shortid from 'shortid'; import mongoose from 'mongoose'; let firstUserIdTest = ''; // will later hold a value returned by our API const firstUserBody = { email: `marcos.henrique+${shortid.generate()}@toptal.com`, password: 'Sup3rSecret!23', }; let accessToken = ''; let refreshToken = ''; const newFirstName = 'Jose'; const newFirstName2 = 'Paulo'; const newLastName2 = 'Faraco';

Em seguida, criaremos um bloco describe() externo com algumas definições de configuração e desmontagem:

 describe('users and auth endpoints', function () { let request: supertest.SuperAgentTest; before(function () { request = supertest.agent(app); }); after(function (done) { // shut down the Express.js server, close our MongoDB connection, then // tell Mocha we're done: app.close(() => { mongoose.connection.close(done); }); }); });

As funções que estamos passando para before() e after() são chamadas antes e depois de todos os testes que definiremos chamando it() dentro do mesmo bloco describe() . A função passada para after() recebe um retorno de chamada, done , que garantimos que seja chamado apenas depois de limparmos o aplicativo e sua conexão com o banco de dados.

Nota: Sem nossa tática after() , o Mocha irá travar mesmo após a conclusão bem-sucedida do teste. O conselho geralmente é simplesmente sempre chamar o Mocha com --exit para evitar isso, mas há uma ressalva (muitas vezes não mencionada). Se o conjunto de testes travar por outros motivos - como uma promessa mal interpretada no conjunto de testes ou no próprio aplicativo - então com --exit , o Mocha não esperará e relatará o sucesso de qualquer maneira, adicionando uma complicação sutil à depuração.

Agora estamos prontos para adicionar testes E2E individuais dentro do bloco describe() :

 it('should allow a POST to /users', async function () { const res = await request.post('/users').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.id).to.be.a('string'); firstUserIdTest = res.body.id; });

Esta primeira função criará um novo usuário para nós - um único, já que nosso e-mail de usuário foi gerado anteriormente usando shortid . A variável request contém um agente SuperTest, permitindo-nos fazer requisições HTTP para nossa API. Nós os fazemos usando await , e é por isso que a função que estamos passando para it() tem que ser async . Em seguida, usamos expect() de Chai para testar vários aspectos do resultado.

Um npm run test neste ponto deve mostrar nosso novo teste funcionando.

Uma cadeia de testes

Adicionaremos todos os seguintes blocos it() dentro do nosso bloco describe() . Temos que adicioná-los na ordem apresentada para que funcionem com as variáveis ​​que estamos alterando, como firstUserIdTest .

 it('should allow a POST to /auth', async function () { const res = await request.post('/auth').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.accessToken).to.be.a('string'); accessToken = res.body.accessToken; refreshToken = res.body.refreshToken; });

Aqui buscamos um novo token de acesso e atualização para nosso usuário recém-criado.

 it('should allow a GET from /users/:userId with an access token', async function () { const res = await request .get(`/users/${firstUserIdTest}`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(200); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body._id).to.be.a('string'); expect(res.body._id).to.equal(firstUserIdTest); expect(res.body.email).to.equal(firstUserBody.email); });

Isso faz uma solicitação GET com token para a rota :userId para verificar se a resposta de dados do usuário corresponde ao que enviamos inicialmente.

Aninhando, pulando, isolando e salvando em testes

No Mocha, os blocos it() também podem conter seus próprios blocos describe() , então vamos aninhar nosso próximo teste dentro de outro bloco describe() . Isso tornará nossa cascata de dependências mais clara na saída do teste, como mostraremos no final.

 describe('with a valid access token', function () { it('should allow a GET from /users', async function () { const res = await request .get(`/users`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(403); }); });

Testes eficazes abrangem não apenas o que esperamos que funcione, mas também o que esperamos que falhe. Aqui, tentamos listar todos os usuários e esperamos uma resposta 403, pois nosso usuário (com permissões padrão) não tem permissão para usar esse endpoint.

Dentro deste novo bloco describe() , podemos continuar escrevendo testes. Como já discutimos os recursos usados ​​no restante do código de teste, ele pode ser encontrado a partir desta linha no repositório.

O Mocha fornece alguns recursos que podem ser convenientes de usar durante o desenvolvimento e depuração de testes:

  1. O método .skip() pode ser usado para evitar a execução de um único teste ou de um bloco inteiro de testes. Quando it() for substituído por it.skip() (da mesma forma para describe() ), o teste ou testes em questão não serão executados, mas serão contabilizados como “pendentes” na saída final do Mocha.
  2. Para uso ainda mais temporário, a função .only() faz com que todos os testes não marcados com .only() sejam completamente ignorados e não resulte em nada marcado como “pendente”.
  3. A invocação de mocha conforme definido em package.json pode usar --bail como um parâmetro de linha de comando. Quando isso é definido, o Mocha para de executar testes assim que um teste falha. Isso é especialmente útil em nosso projeto de exemplo de API REST, pois os testes são configurados para cascata; se apenas o primeiro teste estiver quebrado, o Mocha relata exatamente isso, em vez de reclamar de todos os testes dependentes (mas não quebrados) que agora estão falhando por causa disso.

Se executarmos nossa bateria completa de testes neste momento com npm run test , veremos três testes com falha. (Se deixássemos as funções nas quais elas dependem não implementadas por enquanto, esses três testes seriam bons candidatos para .skip() .)

Os testes com falha dependem de duas peças que estão faltando em nosso aplicativo. A primeira está em users.routes.config.ts :

 this.app.put(`/users/:userId/permissionFlags/:permissionFlags`, [ jwtMiddleware.validJWTNeeded, permissionMiddleware.onlySameUserOrAdminCanDoThisAction, // Note: The above two pieces of middleware are needed despite // the reference to them in the .all() call, because that only covers // /users/:userId, not anything beneath it in the hierarchy permissionMiddleware.permissionFlagRequired( PermissionFlag.FREE_PERMISSION ), UsersController.updatePermissionFlags, ]);

O segundo arquivo que precisamos atualizar é users.controller.ts , já que acabamos de referenciar uma função que não existe lá. Precisaremos adicionar import { PatchUserDto } from '../dto/patch.user.dto'; perto do topo e a função ausente para a classe:

 async updatePermissionFlags(req: express.Request, res: express.Response) { const patchUserDto: PatchUserDto = { permissionFlags: parseInt(req.params.permissionFlags), }; log(await usersService.patchById(req.body.id, patchUserDto)); res.status(204).send(); }

Adicionar essas habilidades de escalonamento de privilégios é útil para testes, mas não atende à maioria dos requisitos do mundo real. Há dois exercícios para o leitor aqui:

  1. Considere maneiras de fazer com que o código novamente não permita que os usuários alterem seus próprios permissionFlags enquanto ainda permite que os endpoints com permissões restritas sejam testados.
  2. Crie e implemente a lógica de negócios (e testes correspondentes) sobre como permissionFlags deve ser capaz de mudar por meio da API. (Há um quebra-cabeça de galinha e ovo aqui: como um usuário específico obtém permissão para alterar as permissões em primeiro lugar?)

Com isso, o npm run test agora deve terminar com sucesso com uma saída bem formatada como esta:

 Index Test ✓ should always pass users and auth endpoints ✓ should allow a POST to /users (76ms) ✓ should allow a POST to /auth ✓ should allow a GET from /users/:userId with an access token with a valid access token ✓ should allow a GET from /users ✓ should disallow a PATCH to /users/:userId ✓ should disallow a PUT to /users/:userId with an nonexistent ID ✓ should disallow a PUT to /users/:userId trying to change the permission flags ✓ should allow a PUT to /users/:userId/permissionFlags/2 for testing with a new permission level ✓ should allow a POST to /auth/refresh-token ✓ should allow a PUT to /users/:userId to change first and last names ✓ should allow a GET from /users/:userId and should have a new full name ✓ should allow a DELETE from /users/:userId 13 passing (231ms)

Agora temos uma maneira de verificar rapidamente se nossa API REST está funcionando conforme o esperado.

Depurando (com) testes

Os desenvolvedores que enfrentam falhas inesperadas nos testes podem facilmente aproveitar o módulo de depuração do Winston e do Node.js ao executar o conjunto de testes.

Por exemplo, é fácil focar em quais consultas do Mongoose são executadas invocando DEBUG=mquery npm run test . (Observe como esse comando não possui o prefixo de export e && no meio, o que faria o ambiente persistir para comandos posteriores.)

Também é possível mostrar toda a saída de depuração com npm run test-debug , graças à nossa adição anterior ao package.json .

Com isso, temos uma API REST funcional, escalável e apoiada pelo MongoDB, com um conveniente conjunto de testes automatizados. Mas ainda faltam alguns itens essenciais.

Segurança (todos os projetos devem usar capacete)

Ao trabalhar com o Express.js, a documentação é uma leitura obrigatória, principalmente suas práticas recomendadas de segurança. No mínimo, vale a pena seguir:

  • Configurando o suporte a TLS
  • Adicionando middleware de limitação de taxa
  • Garantir que as dependências do npm sejam seguras (os leitores podem querer começar com npm audit ou ir mais fundo com o snyk)
  • Usando a biblioteca Helmet para ajudar a proteger contra vulnerabilidades de segurança comuns

Este último ponto é simples de adicionar ao nosso projeto de exemplo:

 npm i --save helmet

Então, em app.ts , precisamos apenas importá-lo e adicionar outra chamada app.use() :

 import helmet from 'helmet'; // ... app.use(helmet());

Como seus documentos apontam, o Helmet (como qualquer adição de segurança) não é uma bala de prata, mas toda prevenção ajuda.

Contendo nosso projeto de API REST com o Docker

Nesta série, não abordamos os contêineres do Docker em profundidade, mas usamos o MongoDB em um contêiner com o Docker Compose. Os leitores que não estão familiarizados com o Docker, mas desejam tentar uma etapa adicional, podem criar um arquivo chamado Dockerfile (sem extensão) na raiz do projeto:

 FROM node:14-slim RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY . . RUN npm install EXPOSE 3000 CMD ["node", "./dist/app.js"]

Essa configuração começa com a imagem oficial node:14-slim do Docker e cria e executa nosso exemplo de API REST em um contêiner. A configuração pode mudar de caso para caso, mas esses padrões de aparência genérica funcionam para nosso projeto.

Para construir a imagem, basta executar isso na raiz do projeto (substituindo tag_your_image_here conforme desejado):

 docker build . -t tag_your_image_here

Então, uma maneira de executar nosso back-end - assumindo exatamente a mesma substituição de texto - é:

 docker run -p 3000:3000 tag_your_image_here

Neste ponto, MongoDB e Node.js podem usar o Docker, mas temos que iniciá-los de duas maneiras diferentes. Deixamos como exercício para o leitor adicionar o aplicativo Node.js principal ao docker-compose.yml para que todo o aplicativo possa ser iniciado com um único comando docker-compose .

Outras habilidades da API REST para explorar

Neste artigo, fizemos grandes melhorias em nossa API REST: adicionamos um MongoDB em contêiner, configuramos o Mongoose e o validador expresso, adicionamos autenticação baseada em JWT e um sistema de permissões flexível e escrevemos uma bateria de testes automatizados.

Este é um ponto de partida sólido para desenvolvedores de back-end novos e avançados. No entanto, de certa forma, nosso projeto pode não ser ideal para uso em produção, dimensionamento e manutenção. Além dos exercícios do leitor que espalhamos ao longo deste artigo, o que mais há para aprender?

No nível da API, recomendamos ler sobre como criar uma especificação compatível com OpenAPI. Os leitores particularmente interessados ​​em buscar o desenvolvimento empresarial também vão querer experimentar o NestJS. É outra estrutura construída em cima do Express.js, mas é mais robusta e abstrata - é por isso que é bom usar nosso projeto de exemplo para se familiarizar com o básico do Express.js primeiro. Não menos importante, a abordagem GraphQL para APIs tem ampla tração como uma alternativa ao REST.

Quando se trata de permissões, abordamos uma abordagem de sinalizadores bit a bit com um gerador de middleware para sinalizadores definidos manualmente. Para maior conveniência ao dimensionar, vale a pena dar uma olhada na biblioteca CASL, que se integra ao Mongoose. Ele estende a flexibilidade de nossa abordagem, permitindo definições sucintas de habilidades que um sinalizador específico deve permitir, como can(['update', 'delete'], '(model name here)', { creator: 'me' }); no lugar de toda uma função de middleware personalizada.

Fornecemos um trampolim de teste automatizado prático neste projeto, mas alguns tópicos importantes estavam além do nosso escopo. Recomendamos aos leitores:

  1. Explore o teste de unidade para testar os componentes separadamente — Mocha e Chai também podem ser usados ​​para isso.
  2. Analise as ferramentas de cobertura de código, que ajudam a identificar lacunas nos conjuntos de testes, mostrando linhas de código que não são executadas durante o teste. Com essas ferramentas, os leitores podem complementar os testes de exemplo, conforme necessário, mas podem não revelar todos os cenários ausentes, como se os usuários podem modificar suas permissões por meio de um PATCH para /users/:userId .
  3. Tente outras abordagens para testes automatizados. Usamos a interface expect no estilo de desenvolvimento orientado a comportamento (BDD) do Chai, mas também suporta should() e assert . Também vale a pena aprender outras bibliotecas de teste, como Jest.

Além desses tópicos, nossa API REST Node.js/TypeScript está pronta para ser construída. Particularmente, os leitores podem querer implementar mais middleware para impor uma lógica de negócios comum em torno do recurso de usuários padrão. Não vou me aprofundar nisso aqui, mas ficaria feliz em fornecer orientações e dicas para os leitores que se encontram bloqueados - basta deixar um comentário abaixo.

O código completo para este projeto está disponível como um repositório GitHub de código aberto.


Leitura adicional no Blog da Toptal Engineering:

  • Como usar rotas Express.js para tratamento de erros baseado em promessa