Criando uma API REST Node.js/TypeScript, Parte 2: Modelos, middleware e serviços

Publicados: 2022-03-11

No primeiro artigo de nossa série de API REST, abordamos como usar o npm para criar um back-end do zero, adicionar dependências como TypeScript, usar o módulo de debug integrado ao Node.js, criar uma estrutura de projeto Express.js e registrar o tempo de execução eventos de forma flexível com Winston. Se você já estiver confortável com esses conceitos, simplesmente clone isso, mude para o toptal-article-01 com git checkout e continue lendo.

Serviços de API REST, Middleware, Controladores e Modelos

Conforme prometido, agora entraremos em detalhes sobre esses módulos:

  • Serviços que tornam nosso código mais limpo encapsulando operações de lógica de negócios em funções que middleware e controladores podem chamar.
  • Middleware que validará as condições de pré-requisito antes que o Express.js chame a função de controlador apropriada.
  • Controladores que usam serviços para processar a solicitação antes de finalmente enviar uma resposta ao solicitante.
  • Modelos que descrevem nossos dados e auxiliam nas verificações em tempo de compilação.

Também adicionaremos um banco de dados muito rudimentar que não é adequado para produção. (Seu único objetivo é tornar este tutorial mais fácil de seguir, abrindo caminho para o nosso próximo artigo aprofundar a conexão de banco de dados e integração com MongoDB e Mongoose.)

Hands-on: Primeiros passos com DAOs, DTOs e nosso banco de dados temporário

Para esta parte do nosso tutorial, nosso banco de dados nem usará arquivos. Ele simplesmente manterá os dados do usuário em uma matriz, o que significa que os dados evaporam sempre que saímos do Node.js. Ele suportará apenas as operações mais básicas de criação, leitura, atualização e exclusão (CRUD).

Vamos usar dois conceitos aqui:

  • Objetos de acesso a dados (DAOs)
  • Objetos de transferência de dados (DTOs)

Essa diferença de uma letra entre os acrônimos é essencial: um DAO é responsável por conectar-se a um banco de dados definido e realizar operações CRUD; um DTO é um objeto que contém os dados brutos que o DAO enviará e receberá do banco de dados.

Em outras palavras, os DTOs são objetos que estão em conformidade com os tipos de modelo de dados e os DAOs são os serviços que os utilizam.

Embora os DTOs possam ficar mais complicados — representando entidades de banco de dados aninhadas, por exemplo — neste artigo, uma única instância de DTO corresponderá a uma ação específica em uma única linha de banco de dados.

Por que DTO?

O uso de DTOs para que nossos objetos TypeScript estejam em conformidade com nossos modelos de dados ajuda a manter a consistência da arquitetura, como veremos na seção sobre serviços abaixo. Mas há uma advertência crucial: nem os DTOs nem o próprio TypeScript prometem qualquer tipo de validação automática de entrada do usuário, pois isso teria que ocorrer em tempo de execução. Quando nosso código recebe entrada do usuário em um endpoint em nossa API, essa entrada pode:

  • Tem campos extras
  • Faltam campos obrigatórios (ou seja, aqueles sem o sufixo ? )
  • Ter campos nos quais os dados não são do tipo que especificamos em nosso modelo usando TypeScript

O TypeScript (e o JavaScript para o qual ele é transpilado) não verificará isso magicamente para nós, por isso é importante não esquecer essas validações, especialmente ao abrir sua API para o público. Pacotes como ajv podem ajudar com isso, mas normalmente funcionam definindo modelos em um objeto de esquema específico de biblioteca em vez de TypeScript nativo. (O mangusto, discutido no próximo artigo, desempenhará um papel semelhante neste projeto.)

Você pode estar pensando: “É realmente melhor usar DAOs e DTOs, em vez de algo mais simples?” O desenvolvedor corporativo Gunther Popp oferece uma resposta; você desejará evitar DTOs na maioria dos projetos Express.js/TypeScript menores do mundo real, a menos que possa esperar uma escala razoável no médio prazo.

Mas mesmo que você não esteja prestes a usá-los em produção, este projeto de exemplo é uma oportunidade valiosa no caminho para dominar a arquitetura da API TypeScript. É uma ótima maneira de praticar o aproveitamento de tipos TypeScript de maneiras adicionais e trabalhar com DTOs para ver como eles se comparam a uma abordagem mais básica ao adicionar componentes e modelos.

Nosso modelo de API REST do usuário no nível do TypeScript

Primeiro vamos definir três DTOs para nosso usuário. Vamos criar uma pasta chamada dto dentro da pasta users e criar um arquivo lá chamado create.user.dto.ts contendo o seguinte:

 export interface CreateUserDto { id: string; email: string; password: string; firstName?: string; lastName?: string; permissionLevel?: number; }

Estamos dizendo que toda vez que criamos um usuário, independentemente do banco de dados, ele deve ter um ID, uma senha e um e-mail e, opcionalmente, um nome e um sobrenome. Esses requisitos podem mudar com base nos requisitos de negócios de um determinado projeto.

Para solicitações PUT , queremos atualizar o objeto inteiro, portanto, nossos campos opcionais agora são obrigatórios. Na mesma pasta, crie um arquivo chamado put.user.dto.ts com este código:

 export interface PutUserDto { id: string; email: string; password: string; firstName: string; lastName: string; permissionLevel: number; }

Para solicitações PATCH , podemos usar o recurso Partial do TypeScript, que cria um novo tipo copiando outro tipo e tornando todos os seus campos opcionais. Dessa forma, o arquivo patch.user.dto.ts precisa conter apenas o seguinte código:

 import { PutUserDto } from './put.user.dto'; export interface PatchUserDto extends Partial<PutUserDto> {}

Agora, vamos criar o banco de dados temporário na memória. Vamos criar uma pasta chamada daos dentro da pasta users e adicionar um arquivo chamado users.dao.ts .

Primeiro, queremos importar os DTOs que criamos:

 import { CreateUserDto } from '../dto/create.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; import { PutUserDto } from '../dto/put.user.dto';

Agora, para lidar com nossos IDs de usuário, vamos adicionar a biblioteca shortid (usando o terminal):

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

De volta a users.dao.ts , importaremos o shortid:

 import shortid from 'shortid'; import debug from 'debug'; const log: debug.IDebugger = debug('app:in-memory-dao');

Agora podemos criar uma classe chamada UsersDao , que ficará assim:

 class UsersDao { users: Array<CreateUserDto> = []; constructor() { log('Created new instance of UsersDao'); } } export default new UsersDao();

Usando o padrão singleton, essa classe sempre fornecerá a mesma instância – e, criticamente, o mesmo array de users – quando a importamos em outros arquivos. Isso porque o Node.js armazena esse arquivo em cache onde quer que ele seja importado, e todas as importações acontecem na inicialização. Ou seja, qualquer arquivo referente a users.dao.ts receberá uma referência ao mesmo new UsersDao() que é exportado na primeira vez que o Node.js processa esse arquivo.

Veremos isso funcionando quando usarmos essa classe mais adiante neste artigo e usarmos esse padrão comum TypeScript/Express.js para a maioria das classes em todo o projeto.

Nota: Uma desvantagem frequentemente citada para singletons é que eles são difíceis de escrever para testes de unidade. No caso de muitas de nossas classes, essa desvantagem não se aplica, pois não há nenhuma variável de membro de classe que precise ser redefinida. Mas para aqueles onde seria, deixamos como exercício para o leitor considerar abordar este problema com o uso de injeção de dependência.

Agora vamos adicionar as operações CRUD básicas à classe como funções. A função de criação ficará assim:

 async addUser(user: CreateUserDto) { user.id = shortid.generate(); this.users.push(user); return user.id; }

A leitura virá em dois sabores, “ler todos os recursos” e “ler um por ID”. Eles são codificados assim:

 async getUsers() { return this.users; } async getUserById(userId: string) { return this.users.find((user: { id: string }) => user.id === userId); }

Da mesma forma, atualização significará sobrescrever o objeto completo (como PUT ) ou apenas partes do objeto (como PATCH ):

 async putUserById(userId: string, user: PutUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1, user); return `${user.id} updated via put`; } async patchUserById(userId: string, user: PatchUserDto) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); let currentUser = this.users[objIndex]; const allowedPatchFields = [ 'password', 'firstName', 'lastName', 'permissionLevel', ]; for (let field of allowedPatchFields) { if (field in user) { // @ts-ignore currentUser[field] = user[field]; } } this.users.splice(objIndex, 1, currentUser); return `${user.id} patched`; }

Como mencionado anteriormente, apesar de nossa declaração UserDto nessas assinaturas de função, o TypeScript não fornece verificação de tipo de tempo de execução. Isso significa que:

  • putUserById() tem um bug. Ele permitirá que os consumidores da API armazenem valores para campos que não fazem parte do modelo definido pelo nosso DTO.
  • patchUserById() depende de uma lista duplicada de nomes de campos que devem ser mantidos em sincronia com o modelo. Sem isso, teria que usar o objeto que está sendo atualizado para esta lista. Isso significaria que ignoraria silenciosamente valores para campos que fazem parte do modelo definido por DTO, mas que não foram salvos antes para essa instância de objeto específica.

Mas ambos os cenários serão tratados corretamente no nível do banco de dados no próximo artigo.

A última operação, para excluir um recurso, ficará assim:

 async removeUserById(userId: string) { const objIndex = this.users.findIndex( (obj: { id: string }) => obj.id === userId ); this.users.splice(objIndex, 1); return `${userId} removed`; }

Como bônus, sabendo que uma pré-condição para criar um usuário é validar se o email do usuário não está duplicado, vamos adicionar uma função “obter usuário por email” agora:

 async getUserByEmail(email: string) { const objIndex = this.users.findIndex( (obj: { email: string }) => obj.email === email ); let currentUser = this.users[objIndex]; if (currentUser) { return currentUser; } else { return null; } }

Observação: em um cenário do mundo real, você provavelmente se conectará a um banco de dados usando uma biblioteca preexistente, como Mongoose ou Sequelize, que abstrairá todas as operações básicas que você possa precisar. Por isso, não vamos entrar em detalhes das funções implementadas acima.

Nossa camada de serviços de API REST

Agora que temos um DAO básico na memória, podemos criar um serviço que chamará as funções CRUD. Como as funções CRUD são algo que todo serviço que se conectará a um banco de dados precisará ter, vamos criar uma interface CRUD que contém os métodos que queremos implementar toda vez que queremos implementar um novo serviço.

Atualmente, os IDEs com os quais trabalhamos possuem recursos de geração de código para adicionar as funções que estamos implementando, reduzindo a quantidade de código repetitivo que precisamos escrever.

Um exemplo rápido usando o WebStorm IDE:

Uma captura de tela do WebStorm mostrando uma definição vazia para uma classe chamada MyService que implementa uma interface chamada CRUD. O nome MyService é sublinhado em vermelho pelo IDE.

O IDE destaca o nome da classe MyService e sugere as seguintes opções:

Uma captura de tela semelhante à anterior, mas com um menu de contexto listando várias opções, sendo a primeira "Implementar todos os membros".

A opção "Implementar todos os membros" fornece instantaneamente as funções necessárias para estar em conformidade com a interface CRUD :

Uma captura de tela da classe MyService no WebStorm. MyService não é mais sublinhado em vermelho, e a definição de classe agora contém todas as assinaturas de função do TypeScript (juntamente com corpos de função, vazios ou contendo uma instrução de retorno) especificadas na interface CRUD.

Dito isso, vamos primeiro criar nossa interface TypeScript, chamada CRUD . Em nossa pasta common , vamos criar uma pasta chamada interfaces e adicionar crud.interface.ts com o seguinte:

 export interface CRUD { list: (limit: number, page: number) => Promise<any>; create: (resource: any) => Promise<any>; putById: (id: string, resource: any) => Promise<string>; readById: (id: string) => Promise<any>; deleteById: (id: string) => Promise<string>; patchById: (id: string, resource: any) => Promise<string>; }

Feito isso, vamos criar uma pasta services dentro da pasta users e adicionar o arquivo users.service.ts lá, contendo:

 import UsersDao from '../daos/users.dao'; import { CRUD } from '../../common/interfaces/crud.interface'; import { CreateUserDto } from '../dto/create.user.dto'; import { PutUserDto } from '../dto/put.user.dto'; import { PatchUserDto } from '../dto/patch.user.dto'; class UsersService implements CRUD { async create(resource: CreateUserDto) { return UsersDao.addUser(resource); } async deleteById(id: string) { return UsersDao.removeUserById(id); } async list(limit: number, page: number) { return UsersDao.getUsers(); } async patchById(id: string, resource: PatchUserDto) { return UsersDao.patchUserById(id, resource); } async readById(id: string) { return UsersDao.getUserById(id); } async putById(id: string, resource: PutUserDto) { return UsersDao.putUserById(id, resource); } async getUserByEmail(email: string) { return UsersDao.getUserByEmail(email); } } export default new UsersService();

Nosso primeiro passo aqui foi importar nosso DAO in-memory, nossa dependência de interface e o tipo TypeScript de cada um de nossos DTOs, é hora de implementar UsersService como um singleton de serviço, o mesmo padrão que usamos com nosso DAO.

Todas as funções CRUD agora basta chamar as respectivas funções de UsersDao . Quando chegar a hora de substituir o DAO, não teremos que fazer alterações em nenhum outro lugar do projeto, exceto alguns ajustes neste arquivo onde as funções do DAO são chamadas, como veremos na Parte 3.

Por exemplo, não teremos que rastrear todas as chamadas para list() e verificar seu contexto antes de substituí-lo. Essa é a vantagem de ter essa camada de separação, ao custo da pequena quantidade de clichê inicial que você vê acima.

Async/Await e Node.js

Nosso uso de async para as funções de serviço pode parecer inútil. Por enquanto, é: Todas essas funções retornam seus valores imediatamente, sem nenhum uso interno de Promise s ou await . Isso é apenas para preparar nossa base de código para serviços que usarão async . Da mesma forma, abaixo, você verá que todas as chamadas para essas funções usam await .

No final deste artigo, você terá novamente um projeto executável para experimentar. Esse será um excelente momento para tentar adicionar vários tipos de erros em diferentes lugares da base de código e ver o que acontece durante a compilação e os testes. Erros em um contexto async em particular podem não se comportar como o esperado. Vale a pena pesquisar e explorar várias soluções, que estão além do escopo deste artigo.


Agora, com nosso DAO e serviços prontos, vamos voltar ao controlador de usuário.

Construindo nosso controlador de API REST

Como dissemos acima, a ideia por trás dos controladores é separar a configuração da rota do código que finalmente processa uma solicitação de rota. Isso significa que todas as validações devem ser feitas antes que nossa solicitação chegue ao controlador. O controlador só precisa saber o que fazer com a solicitação real porque, se a solicitação chegou até aqui, sabemos que ela foi válida. O controlador então chamará o respectivo serviço de cada solicitação que estiver tratando.

Antes de começarmos, precisaremos instalar uma biblioteca para fazer o hash seguro da senha do usuário:

 npm i argon2

Vamos começar criando uma pasta chamada controllers dentro da pasta users controller e criando um arquivo chamado users.controller.ts nela:

 // we import express to add types to the request/response objects from our controller functions import express from 'express'; // we import our newly created user services import usersService from '../services/users.service'; // we import the argon2 library for password hashing import argon2 from 'argon2'; // we use debug with a custom context as described in Part 1 import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersController { async listUsers(req: express.Request, res: express.Response) { const users = await usersService.list(100, 0); res.status(200).send(users); } async getUserById(req: express.Request, res: express.Response) { const user = await usersService.readById(req.body.id); res.status(200).send(user); } async createUser(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); const userId = await usersService.create(req.body); res.status(201).send({ id: userId }); } async patch(req: express.Request, res: express.Response) { if (req.body.password) { req.body.password = await argon2.hash(req.body.password); } log(await usersService.patchById(req.body.id, req.body)); res.status(204).send(); } async put(req: express.Request, res: express.Response) { req.body.password = await argon2.hash(req.body.password); log(await usersService.putById(req.body.id, req.body)); res.status(204).send(); } async removeUser(req: express.Request, res: express.Response) { log(await usersService.deleteById(req.body.id)); res.status(204).send(); } } export default new UsersController();

Observação: as linhas acima não enviando nada com uma resposta HTTP 204 No Content estão de acordo com a RFC 7231 sobre o tópico.

Com nosso singleton de controlador de usuário pronto, estamos prontos para codificar o outro módulo que depende de nosso modelo de objeto e serviço de API REST de exemplo: nosso middleware de usuário.

Middleware REST Node.js com Express.js

O que podemos fazer com o middleware Express.js? As validações são uma ótima opção, por exemplo. Vamos adicionar algumas validações básicas para atuar como gatekeepers para solicitações antes que elas cheguem ao nosso controlador de usuário:

  • Garanta a presença de campos de usuário, como email e password , conforme necessário para criar ou atualizar um usuário
  • Verifique se um determinado e-mail já não está em uso
  • Verifique se não estamos alterando o campo de e- email após a criação (já que estamos usando isso como o ID principal voltado para o usuário para simplificar)
  • Validar se um determinado usuário existe

Para que essas validações funcionem com o Express.js, precisaremos traduzi-las em funções que seguem o padrão Express.js de controle de fluxo usando next() , conforme descrito no artigo anterior. Vamos precisar de um novo arquivo, users/middleware/users.middleware.ts :

 import express from 'express'; import userService from '../services/users.service'; import debug from 'debug'; const log: debug.IDebugger = debug('app:users-controller'); class UsersMiddleware { } export default new UsersMiddleware();

Com o clichê de singleton familiar fora do caminho, vamos adicionar algumas de nossas funções de middleware ao corpo da classe:

 async validateRequiredUserBodyFields( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.email && req.body.password) { next(); } else { res.status(400).send({ error: `Missing required fields email and password`, }); } } async validateSameEmailDoesntExist( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user) { res.status(400).send({ error: `User email already exists` }); } else { next(); } } async validateSameEmailBelongToSameUser( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.getUserByEmail(req.body.email); if (user && user.id === req.params.userId) { next(); } else { res.status(400).send({ error: `Invalid email` }); } } // Here we need to use an arrow function to bind `this` correctly validatePatchEmail = async ( req: express.Request, res: express.Response, next: express.NextFunction ) => { if (req.body.email) { log('Validating email', req.body.email); this.validateSameEmailBelongToSameUser(req, res, next); } else { next(); } }; async validateUserExists( req: express.Request, res: express.Response, next: express.NextFunction ) { const user = await userService.readById(req.params.userId); if (user) { next(); } else { res.status(404).send({ error: `User ${req.params.userId} not found`, }); } }

Para tornar mais fácil para nossos consumidores de API fazerem solicitações adicionais sobre um usuário recém-adicionado, adicionaremos uma função auxiliar que extrairá o userId dos parâmetros de solicitação - vindo do próprio URL da solicitação - e o adicionará ao corpo da solicitação, onde reside o restante dos dados do usuário.

A ideia aqui é poder simplesmente usar a solicitação de corpo inteiro quando quisermos atualizar as informações do usuário, sem nos preocuparmos em obter o ID dos parâmetros todas as vezes. Em vez disso, ele é cuidado em apenas um ponto, o middleware. A função ficará assim:

 async extractUserId( req: express.Request, res: express.Response, next: express.NextFunction ) { req.body.id = req.params.userId; next(); }

Além da lógica, a principal diferença entre o middleware e o controlador é que agora estamos usando a função next() para passar o controle ao longo de uma cadeia de funções configuradas até chegar ao destino final, que no nosso caso é o controlador.

Juntando tudo: refatorando nossas rotas

Agora que implementamos todos os novos aspectos da arquitetura do nosso projeto, vamos voltar ao arquivo users.routes.config.ts que definimos no artigo anterior. Ele chamará nosso middleware e nossos controladores, os quais dependem de nosso serviço de usuário, o que, por sua vez, requer nosso modelo de usuário.

O arquivo final será tão simples quanto isto:

 import { CommonRoutesConfig } from '../common/common.routes.config'; import UsersController from './controllers/users.controller'; import UsersMiddleware from './middleware/users.middleware'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes(): express.Application { this.app .route(`/users`) .get(UsersController.listUsers) .post( UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailDoesntExist, UsersController.createUser ); this.app.param(`userId`, UsersMiddleware.extractUserId); this.app .route(`/users/:userId`) .all(UsersMiddleware.validateUserExists) .get(UsersController.getUserById) .delete(UsersController.removeUser); this.app.put(`/users/:userId`, [ UsersMiddleware.validateRequiredUserBodyFields, UsersMiddleware.validateSameEmailBelongToSameUser, UsersController.put, ]); this.app.patch(`/users/:userId`, [ UsersMiddleware.validatePatchEmail, UsersController.patch, ]); return this.app; } }

Aqui, redefinimos nossas rotas adicionando middleware para validar nossa lógica de negócios e as funções apropriadas do controlador para processar a solicitação se tudo for válido. Também usamos a função .param() do Express.js para extrair o userId .

Na função .all .all() , estamos passando nossa função validateUserExists de UsersMiddleware para ser chamada antes que qualquer GET , PUT , PATCH ou DELETE possa passar no endpoint /users/:userId . Isso significa que validateUserExists não precisa estar nos arrays de funções adicionais que passamos para .put() ou .patch() — ele será chamado antes das funções especificadas lá.

Também aproveitamos a reutilização inerente do middleware aqui de outra maneira. Ao passar UsersMiddleware.validateRequiredUserBodyFields para ser usado em contextos POST e PUT , estamos recombinando-o elegantemente com outras funções de middleware.

Isenções de responsabilidade: abordamos apenas validações básicas neste artigo. Em um projeto do mundo real, você precisará pensar e encontrar todas as restrições necessárias para codificar. Para simplificar, também estamos assumindo que um usuário não pode alterar seu e-mail.

Testando nossa API REST Express/TypeScript

Agora podemos compilar e executar nosso aplicativo Node.js. Quando estiver em execução, estamos prontos para testar nossas rotas de API usando um cliente REST, como Postman ou cURL.

Vamos primeiro tentar obter nossos usuários:

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

Neste ponto, teremos um array vazio como resposta, o que é preciso. Agora podemos tentar criar o primeiro recurso do usuário com isso:

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

Observe que agora nosso aplicativo Node.js retornará um erro do nosso middleware:

 { "error": "Missing required fields email and password" }

Para corrigi-lo, vamos enviar uma solicitação válida para postar no recurso /users :

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

Desta vez, devemos ver algo como o seguinte:

 { "id": "ksVnfnPVW" }

Este id é o identificador do usuário recém-criado e será diferente em sua máquina. Para facilitar as instruções de teste restantes, você pode executar este comando com o que obtiver (supondo que esteja usando um ambiente semelhante ao Linux):

 REST_API_EXAMPLE_

Agora podemos ver a resposta que recebemos ao fazer uma solicitação GET usando a variável acima:

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

Agora também podemos atualizar todo o recurso com a seguinte solicitação PUT :

 curl --request PUT "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "email": "[email protected]", "password": "sup3rS3cr3tPassw0rd!23", "firstName": "Marcos", "lastName": "Silva", "permissionLevel": 8 }'

Também podemos testar se nossa validação funciona alterando o endereço de e-mail, o que deve resultar em um erro.

Observe que, ao usar um PUT para um ID de recurso, nós, como consumidores de API, precisamos enviar o objeto inteiro se quisermos estar em conformidade com o padrão REST padrão. Isso significa que se quisermos atualizar apenas o campo lastName , mas usando nosso endpoint PUT , seremos forçados a enviar o objeto inteiro para ser atualizado. Seria mais fácil usar uma solicitação PATCH , pois ainda está dentro das restrições REST padrão para enviar apenas o campo lastName :

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

Lembre-se de que em nossa própria base de código, é nossa configuração de rota que impõe essa distinção entre PUT e PATCH usando as funções de middleware que adicionamos neste artigo.

PUT , PATCH ou ambos?

Pode parecer que não há muitos motivos para oferecer suporte a PUT dada a flexibilidade de PATCH , e algumas APIs adotarão essa abordagem. Outros podem insistir em oferecer suporte a PUT para tornar a API “completamente compatível com REST”, nesse caso, criar rotas PUT por campo pode ser uma tática apropriada para casos de uso comuns.

Na realidade, esses pontos fazem parte de uma discussão muito maior, desde diferenças da vida real entre os dois até semânticas mais flexíveis apenas para PATCH . Apresentamos o suporte PUT aqui e a semântica PATCH amplamente utilizada para simplificar, mas incentivamos os leitores a aprofundar a pesquisa quando se sentirem prontos para fazê-lo.

Obtendo a lista de usuários novamente como fizemos acima, devemos ver nosso usuário criado com seus campos atualizados:

 [ { "id": "ksVnfnPVW", "email": "[email protected]", "password": "$argon2i$v=19$m=4096,t=3,p=1$ZWXdiTgb922OvkNAdh9acA$XUXsOHaRN4uVg5ltIwwO+SPLxvb9uhOKcxoLER1e/mM", "firstName": "Marcos", "lastName": "Faraco", "permissionLevel": 8 } ]

Finalmente, podemos testar a exclusão do usuário com isso:

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

Obtendo a lista de usuários novamente, devemos ver que o usuário excluído não está mais presente.

Com isso, temos todas as operações CRUD para o recurso de users funcionando.

API REST Node.js/TypeScript

Nesta parte da série, exploramos ainda mais as principais etapas na criação de uma API REST usando o Express.js. Dividimos nosso código para dar suporte a serviços, middleware, controladores e modelos. Cada uma de suas funções tem uma função específica, seja validação, operações lógicas ou processamento de solicitações válidas e resposta a elas.

Também criamos uma maneira muito simples de armazenar dados, com o propósito expresso (desculpe o trocadilho) de permitir alguns testes neste momento, sendo substituído por algo mais prático na próxima parte de nossa série.

Além de construir uma API com simplicidade em mente - usando classes singleton, por exemplo - há várias etapas a serem seguidas para torná-la mais fácil de manter, mais escalável e segura. No último artigo da série, abordamos:

  • Substituindo o banco de dados na memória pelo MongoDB e usando o Mongoose para simplificar o processo de codificação
  • Adicionando uma camada de segurança e controle o acesso em uma abordagem sem estado com JWT
  • Configurando testes automatizados para permitir que nosso aplicativo seja dimensionado

Você pode navegar pelo código final deste artigo aqui.