Criando um Clone do Google Keep [Parte 2]

No último post, comecei a mostrar como criei um Clone do famoso Google Keep e apresentei o que está por trás do frontend da minha aplicação. Se você não leu ainda, vai lá, eu te espero: Parte 1.

Ah, e se em qualquer momento você quiser acessar o código completo do projeto, ele está disponível neste repositório no meu GitHub.

Nesta segunda parte, quero mergulhar com vocês no backend do app e mostrar de onde vêm todas aquelas notas e tags, como é feita a autenticação, como é estruturado o servidor, etc.

Ingredientes

As principais tecnologias com as quais o backend foi desenvolvido são: Node.js, Express e TypeScript.

O que cada um desses nomes significa a fundo é assunto pra outros posts, mas por enquanto (se ainda não conhecia), saiba que:

  • Node.js é um ambiente de execução de código JavaScript no lado do servidor, open-source e multiplataforma. Ele em si não é uma linguagem de programação, mas sim uma plataforma para aplicações.
  • Express é o web framework mais famoso atualmente para Node.js. Com ele você consegue criar aplicações e web APIs de forma rápida e fácil, sem ficar “reinventando a roda”.
  • TypeScript é um superset (“superconjunto”) de JavaScript desenvolvido pela Microsoft, que adiciona tipagem e alguns outros recursos ao código (já definimos o termo rapidamente na Parte 1)

E, antes de entrarmos no código, é importante entender alguns conceitos/padrões que adotei na construção dele.

Padrões e boas práticas

DDD (Domain-driven design)

Segundo a Wikipédia,

O Domain-driven design (DDD) é o conceito de que a estrutura e a linguagem do código do software (nomes de classe, métodos de classe, variáveis de classe) devem corresponder ao domínio do negócio.

Em outras palavras, o DDD é uma abordagem de desenvolvimento de software focada no domínio da aplicação. Um domínio pode ser uma atividade-chave ou uma área de conhecimento do negócio/projeto.

Meu objetivo obviamente não é entrar muito a fundo no conceito e tudo o que ele envolve já que o Eric Evans, quem cunhou o conceito, tem um livro inteiro sobre o assunto (e com certeza explicará melhor do que eu 😅).

A abordagem em si define vários princípios a serem seguidos, só estou pegando emprestado um desses conceitos, que é o de domínio. Assim, cada módulo da nossa aplicação diz respeito a uma “área de conhecimento”, a um domínio.

Ao abrir a pasta do backend, podemos visualizar isso, já que há uma pasta de modules, com subpastas notes e users.

Keep Clone Backend structure

A parte que lida com as coisas dos usuários não tem praticamente nada a ver com a parte que trata das notas, certo? São domínios diferentes. Portanto, no Keep Clone, há o domínio de usuários e o domínio de notas. O domínio de notas abrange tudo relacionado a elas, ou seja, tanto notas quanto tags.

Repository

O Repository é um conceito introduzido no Data Mapper Pattern ou Repository Pattern que consiste em uma ponte entre nossa aplicação e a fonte de dados, seja ela um banco de dados, um arquivo físico ou qualquer outro meio de persistência de dados da aplicação.

Essa implementação visa isolar a forma com que nos comunicamos com os dados, abstraindo lógicas comuns de operações no banco. Geralmente o Repository possui os métodos comuns de comunicação com uma fonte de dados como listagem, busca, criação, edição, remoção.

Service

O Service é um conceito introduzido no Service Pattern. Ele tem como objetivo abstrair regras de negócio das rotas, além de tornar nosso código mais reutilizável. Assim, reduzimos a complexidade das rotas da nossa aplicação e deixamos elas responsáveis apenas por receber uma requisição e repassar os dados da requisição a outro arquivo responsável por lidar com ela e devolver uma resposta.

O Service deve ter um nome descritivo (ex.: CreateNoteService) e sempre possuir apenas um método (ex.: execute()). Além disso, caso outra rota ou arquivo precise executar essa mesma ação, basta chamar e executar esse Service, obedecendo assim a outro importante princípio: DRY (Don’t Repeat Yourself).

Sobre padrões e arquitetura

Uma coisa sobre o Express (e o Node em geral) é que ele é bem mínimo e “não-opinado”, ou seja, ele não te força a seguir um certo padrão (e isso pode ser tanto uma vantagem como uma desvantagem). Por isso, você é livre (e obrigado!) a definir essas opiniões/padrões por conta própria.

Por isso, esses padrões que citei e estarei utilizando são apenas algumas das opções disponíveis (e quando o assunto é arquitetura de software, acredite, não existe só um jeito de fazer!).

Guardando os dados

Para armazenar os dados gerados e consumidos pelo Keep Clone, escolhi utilizar o MySQL, um dos gerenciadores de banco de dados mais populares atualmente.

Ele é um banco de dados relacional, no qual os dados se encontram em tabelas bem definidas que podem possuir relacionamentos entre si, associando um ou mais campos de uma tabela a um ou mais campos de outra.

Para utilizá-lo na minha máquina criei um container pelo Docker, utilizando a imagem oficial do MySQL na versão 8.0.22. Se você já tem o Docker instalado, bastaria rodar algo assim para chegar no mesmo resultado:

docker run --name keep-clone -e MYSQL_ROOT_PASSWORD=docker -p 3306:3306 -d mysql:8.0.22

E, claro, ao fazer deploy o ideal seria utilizar um serviço de managed databases.

O servidor

A grosso modo, no servidor do Keep Clone tudo começa pelo arquivo server.ts (algumas partes foram omitidas com um “[...]”):

[...]

// Inicializamos o Express
const app = express();

// Algumas coisas que queremos que o Express utilize:

app.use(helmet());
app.use(cors({
  origin: process.env.APP_WEB_URL,
}));
app.use(express.json());
app.use(rateLimiter);
app.use(routes);

[...]

// Pedimos para que o server "ouça" requisições na porta 3333
 
app.listen(3333, () => {
  console.log('Server started on port 3333!');
});

Nele, iniciamos o Express e configuramos algumas coisas, tais como:

  • O Helmet, que é uma lib que ajuda a proteger apps Express ao setar vários headers HTTP. No total são 11 “proteções”;
  • O CORS, um mecanismo que permite que recursos restritos em uma página da web sejam recuperados por outro domínio. Neste caso configuramos para que o nosso backend só receba requisições do nosso frontend, determinado por aquela variável de ambiente ali em origin.
  • E também o Sentry (essa parte foi omitida), que é um serviço que ajuda a monitorar e corrigir falhas em tempo real, e seria muito útil no caso de colocarmos o Keep Clone em produção.

Também pedimos para que o Express use as nossas rotas ( app.use(routes); ), que é o que eu gostaria de mostrar agora.

Rotas

O arquivo principal de rotas (abaixo) concentra o uso de todos os Routers que temos na aplicação (usersRouter, notesRouter, etc.).

// src/shared/infra/http/routes/index.ts

import { Router } from 'express';

import usersRouter from '@modules/users/infra/http/routes/users.routes';
import passwordRouter from '@modules/users/infra/http/routes/password.routes';
import sessionsRouter from '@modules/users/infra/http/routes/sessions.routes';
import notesRouter from '@modules/notes/infra/http/routes/notes.routes';
import tagsRouter from '@modules/notes/infra/http/routes/tags.routes';

const routes = Router();

routes.use('/users', usersRouter);
routes.use('/password', passwordRouter);
routes.use('/sessions', sessionsRouter);
routes.use('/notes', notesRouter);
routes.use('/tags', tagsRouter);

export default routes;

Segundo a documentação do Express, “o roteamento refere-se à definição de terminais do aplicativo (URIs) e como eles respondem às solicitações do cliente”.

Em termos práticos, da forma como o arquivo acima está programado, sempre que for feita uma solicitação à uma rota começando com /users, por exemplo, ela será tratada pelo usersRouter; se começar com /notes, ela será tratada pelo notesRouter, e assim por diante.

O que acontece dentro de cada router fica mais claro quando olhamos o notesRouter, por exemplo:

// src/modules/notes/infra/http/routes/notes.routes.ts

// Aqui são só as importações, normal
import { Router } from 'express';
import { celebrate, Segments, Joi } from 'celebrate';

import NotesController from '../controllers/NotesController';
import ArchivedNotesController from '../controllers/ArchivedNotesController';
import ensureAuthenticated from '@shared/infra/http/middlewares/ensureAuthenticated';
import NoteStatus from '@modules/notes/dtos/NoteStatus';
import NoteColors from '@modules/notes/dtos/NoteColors';

// Inicializamos este router
const notesRouter = Router();

// Inicializamos os controllers (já veremos o que são)
const notesController = new NotesController();
const archivedNotesController = new ArchivedNotesController();

[...]

// Usamos o middleware "ensureAuthenticated", que garante que
// o usuário esteja autenticado, em todas as rotas a seguir
notesRouter.use(ensureAuthenticated);

// E então declaramos os nossos endpoints de fato:

notesRouter.get(
  '/',
  celebrate({
    [Segments.QUERY]: {
      tag_id: Joi.string().uuid(),
      query: Joi.string().allow(''),
      status: Joi.number().valid(...getEnumValues(NoteStatus)).required(),
      page: Joi.number().min(1).default(1),
    },
  }),
  notesController.index,
);

notesRouter.post(
  '/',
  celebrate({
    [Segments.BODY]: {
      title: Joi.string().max(255).allow(''),
      body: Joi.string().max(65000).required(),
      color: Joi.number().valid(...getEnumValues(NoteColors)).required(),
      tag_id: Joi.string().uuid().allow(''),
    },
  }),
  notesController.create,
);

notesRouter.patch(
  '/:id',
  celebrate({
    [Segments.PARAMS]: {
      id: Joi.string().uuid().required(),
    },
    [Segments.BODY]: {
      title: Joi.string().max(255).allow(''),
      body: Joi.string().max(65000),
      color: Joi.number().valid(...getEnumValues(NoteColors)),
      tag_id: Joi.string().uuid().allow(''),
    },
  }),
  notesController.update,
);

notesRouter.delete(
  '/:id',
  celebrate({
    [Segments.PARAMS]: {
      id: Joi.string().uuid().required(),
    },
  }),
  notesController.destroy,
);

notesRouter.post(
  '/archive/:id',
  celebrate({
    [Segments.PARAMS]: {
      id: Joi.string().uuid().required(),
    },
  }),
  archivedNotesController.create,
);

notesRouter.delete(
  '/archive/:id',
  celebrate({
    [Segments.PARAMS]: {
      id: Joi.string().uuid().required(),
    },
  }),
  archivedNotesController.destroy,
);

export default notesRouter;

A definição de rotas com Express segue a seguinte estrutura:

app.METHOD(PATH, HANDLER)

em que:

  • app é uma instância do Express;
  • METHOD é um método de solicitação HTTP, como GET, POST, PATCH e DELETE;
  • PATH é um caminho no servidor, como “/notes”
  • HANDLER é a função executada quando a rota é correspondida.

Então, o que acontece quando declaramos

notesRouter.get(
  '/',
  celebrate({
    [Segments.QUERY]: {
      tag_id: Joi.string().uuid(),
      query: Joi.string().allow(''),
      status: Joi.number().valid(...getEnumValues(NoteStatus)).required(),
      page: Joi.number().min(1).default(1),
    },
  }),
  notesController.index,
);

é que temos uma rota de método GET, no caminho raiz deste router. Isto é, se está sendo tratada por este router é porque a requisição começava com “/notes”. Somado à “/” desta rota, temos um GET em “/notes/”, ou simplesmente “/notes”. Enviamos os dados da requisição para a função celebrate() e depois para a função notesController.index().

O celebrate é uma função middleware que empacota a famosa biblioteca de validação joi. (Middleware não tem exatamente uma tradução em português, mas entenda como uma função que fica no meio de outras.)

Essa função do celebrate permite que nós usemos esse middleware em qualquer rota, seja individual ou globalmente, garantindo que todas as entradas são válidas antes de fazer qualquer outra coisa com elas.

No exemplo acima quero que tag_id seja uma string UUID válida, que query seja uma string (mas pode ser vazia), e assim por diante.

Controllers

Como as responsabilidades estão bem divididas no código, as rotas só são responsáveis por receber a requisição, validá-la e, caso seja válida, encaminhá-la a um controller para que seja resolvida.

Temos como exemplo o NotesController (o controller de notas), que é definido no arquivo abaixo:

// src/modules/notes/infra/http/controllers/NotesController.ts

import { Request, Response } from 'express';
import { container } from 'tsyringe';

// Importamos os serviços usados por esse controller
import CreateNoteService from '@modules/notes/services/CreateNoteService';
import ListNotesService from '@modules/notes/services/ListNotesService';
import UpdateNoteService from '@modules/notes/services/UpdateNoteService';
import DeleteNoteService from '@modules/notes/services/DeleteNoteService';
import { classToClass } from 'class-transformer';

export default class NotesController {
  public async index(request: Request, response: Response): Promise<Response> {
    const { tag_id, query, status, page } = request.query;
    const { id: user_id } = request.user;

    const listNotes = container.resolve(ListNotesService);

    const { data, count } = await listNotes.execute({
      user_id,
      tag_id: tag_id ? String(tag_id) : undefined,
      query: query ? String(query) : undefined,
      status: Number(status),
      page: Number(page),
    });

    return response.json({ data: classToClass(data), count });
  }

  public async create(request: Request, response: Response): Promise<Response> {
    const { title, body, color, tag_id } = request.body;
    const { id: user_id } = request.user;

    const createNote = container.resolve(CreateNoteService);

    const note = await createNote.execute({
      user_id,
      title,
      body,
      color,
      tag_id,
    });

    return response.json(classToClass(note));
  }

  public async update(request: Request, response: Response): Promise<Response> {
    [...]
  }

  public async destroy(request: Request, response: Response): Promise<Response> {
    [...]
  }
}

Diferente do que é definido no padrão de arquitetura MVC (Model-View-Controller), — onde os controllers basicamente seriam responsáveis por todas as regras de negócio —, no código do Keep Clone eles somente são responsáveis por pegar os dados da requisição e usá-los para chamar um service (lembra do pattern que comentei lá no começo?).

Por convenção, meus controllers tem até 5 métodos: index, show, create, update e delete. E, quando preciso de algum método além, geralmente é um sinal de que preciso criar um outro Controller.

Continuando, vamos pegar como exemplo o método create desse controller. Ele pega alguns dados do corpo (body) da requisição, como título e cor, bem como recupera o id do usuário fazendo esta requisição (dado que já salvei com o meu middleware de autenticação).

public async create(request: Request, response: Response): Promise<Response> {
  const { title, body, color, tag_id } = request.body;
  const { id: user_id } = request.user;

  const createNote = container.resolve(CreateNoteService);

  const note = await createNote.execute({
    user_id,
    title,
    body,
    color,
    tag_id,
  });

  return response.json(classToClass(note));
}

A partir disso, executa o service para criação de nota, o CreateNoteService. Em alguns casos, como este, o que recebo do service não é exatamente o que quero retornar para o cliente que fez a requisição. Por isso faço uma transformação do dado recebido usando a lib class-transformer na linha onde há classToClass(note). O que é feito na transformação será mostrado em breve.

Services

Vamos ver o CreateNoteService então:

// src/modules/notes/services/CreateNoteService.ts

import { injectable, inject } from 'tsyringe';

import IUsersRepository from '@modules/users/repositories/IUsersRepository';
import ITagsRepository from '../repositories/ITagsRepository';
import INotesRepository from '../repositories/INotesRepository';
import Note from '../infra/typeorm/entities/Note';
import AppError from '@shared/errors/AppError';
import NoteStatus from '../dtos/NoteStatus';

interface IRequest {
  user_id: string;
  title: string;
  body: string;
  color: number;
  tag_id?: string;
}

@injectable()
class CreateNoteService {
  constructor(
    @inject('UsersRepository')
    private usersRepository: IUsersRepository,

    @inject('TagsRepository')
    private tagsRepository: ITagsRepository,

    @inject('NotesRepository')
    private notesRepository: INotesRepository,
  ) {}

  public async execute({ user_id, title, body, color, tag_id }: IRequest): Promise<Note> {
    const user = await this.usersRepository.findById(user_id);
    if (!user) throw new AppError('User not found.', 404);

    if (tag_id) {
      const tag = await this.tagsRepository.findById(tag_id);
      if (!tag) throw new AppError('Tag not found.', 404);
      if (tag.user_id !== user_id) throw new AppError('You cannot use this tag.', 403);
    }

    const note = await this.notesRepository.create({
      user_id,
      title,
      body,
      color,
      tag_id: tag_id ? tag_id : undefined,
      status: NoteStatus.ACTIVE,
    });

    return note;
  }
}

export default CreateNoteService;

Algumas observações:

  • O service só tem um método, aqui chamado de execute(). É nele que a mágica acontece;
  • Para este service funcionar, foi preciso ter acesso aos usuários, tags e notas. Como abstraímos o banco de dados em repositories, é eles que utilizamos para tal.
    • Para não ter que instanciar manualmente cada repositório, utilizamos injeção de dependências através da lib tsyringe; Se você não sabe o que isso significa, não se preocupe, não é o foco aqui.
  • Notou que este service contém as regras de negócio para a criação de uma nova nota? Esse é o único trabalho dele. Neste caso, é verificado se o usuário em questão existe, se a tag (caso tenha sido informada) existe, e só então criamos uma nota com os dados informados.

Os outros services seguem a mesma essência, porém cada um com o seu trabalho.

Repositories

Bem, falamos nos repositories e vimos sua aplicação no service acima, mas não vimos como eles são definidos. Então aqui vai o NotesRepository:

// src/modules/notes/infra/typeorm/repositories/NotesRepository.ts

import { getRepository, Repository } from 'typeorm';

import INotesRepository from '@modules/notes/repositories/INotesRepository';
import ICreateNoteDTO from '@modules/notes/dtos/ICreateNoteDTO';
import IFindAllNotesDTO from '@modules/notes/dtos/IFindAllNotesDTO';
import Note from '../entities/Note';
import IFindAllNotesResponseDTO from '@modules/notes/dtos/IFindAllNotesResponseDTO';

class NotesRepository implements INotesRepository {
  private ormRepository: Repository<Note>;

  constructor() {
    this.ormRepository = getRepository(Note);
  }

  public async findById(id: string): Promise<Note | undefined> {
    const note = await this.ormRepository.findOne(id, {
      relations: ['tag']
    });

    return note;
  }

  public async findAll({ user_id, tag_id, query, status, page }: IFindAllNotesDTO): Promise<IFindAllNotesResponseDTO> {
    let dbQuery = this.ormRepository
      .createQueryBuilder('note')
      .leftJoinAndSelect("note.tag", "tag");

    [... Toda uma mexida nessa dbQuery para fazer a filtragem das notas ... ]

    const [data, count] = await dbQuery
      .skip(25 * (page - 1))
      .take(25)
      .orderBy('note.created_at', 'DESC')
      .getManyAndCount();

    return { data, count };
  }

  public async create(noteData: ICreateNoteDTO): Promise<Note> {
    const note = this.ormRepository.create(noteData);

    await this.ormRepository.save(note);

    return note;
  }

  public async save(Note: Note): Promise<Note> {
    return this.ormRepository.save(Note);
  }

  public async delete(note: Note): Promise<void> {
    this.ormRepository.remove(note);
  }
}

export default NotesRepository;

Note que neste repositório definimos as operações que queremos fazer com as notas que estão no banco de dados.

Para usar o banco de dados, temos 3 formas, da mais baixo nível até a mais abstrata/prática:

  1. Usando um driver nativo, como o mysql2;
  2. Usando um query builder, como o knex.js; ou
  3. Usando um Object-Relational Mapping (ou ORM), como Sequelize ou (especialmente com Typescript) o TypeORM, que foi a minha escolha para o projeto.

Um ORM faz o mapeamento das entidades do banco para classes/interfaces/módulos/estruturas (seja lá como a linguagem chame) no código da aplicação e também mapeia os relacionamentos.

Na maior parte do tempo, ao usar um ORM, não precisamos escrever SQL “na mão”, só pra alguma query muito elaborada (o que chegou mais perto disso foi filtragem ao listar as notas).

Entities

Vamos ver como um modelo do banco de dados pode ser representado no código com o auxílio do TypeORM, neste caso a classe Note, que define uma nota:

import NoteColors from '@modules/notes/dtos/NoteColors';
import NoteStatus from '@modules/notes/dtos/NoteStatus';
import { Exclude } from 'class-transformer';
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  CreateDateColumn,
  UpdateDateColumn,
  ManyToOne,
  JoinColumn,
} from 'typeorm';
import Tag from './Tag';

@Entity('notes')
class Note {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Exclude()
  @Column('uuid')
  user_id: string;

  @Column({ type: 'varchar' })
  title: string | null;

  @Column()
  body: string;

  @Column({ enum: NoteColors })
  color: number;

  @ManyToOne(() => Tag)
  @JoinColumn({ name: 'tag_id' })
  tag: Tag | null;

  @Exclude()
  @Column({ type: 'uuid' })
  tag_id: string | null;

  @Column({ enum: NoteStatus })
  status: number;

  @CreateDateColumn()
  created_at: Date;

  @UpdateDateColumn()
  updated_at: Date;
}

export default Note;

Note como usamos decoradores (esses marcadores que começam com um “@”) do TypeORM para caracterizar cada campo.

Ah, e lembra daquela transformação que fiz com o objeto de nota lá no NotesController? Aqui podemos ver que transformação era essa: em cima dos atributos user_id e tag_id tem um decorador @Exclude da lib class-transformer. Isso quer dizer que quero excluir esses campos quando fizer a transformação, porque não me interessam no frontend.

Existem outros decoradores como o @Expose, em que você expõe um dado, podendo usar e combinar outros dados da entidade, além das opções que podemos passar para esses decoradores. Enfim, é bem divertido 🙂.

Migrations

Mas como essas tabelas são criadas e alteradas no banco de dados? Bem, com o TypeORM (e outros ORMs também) podemos fazer isso através de migrations, como a migration que cria a tabela de notas:

// src/shared/infra/typeorm/migrations/1609592066731-CreateNotes.ts

import { MigrationInterface, QueryRunner, Table } from "typeorm";

export default class CreateNotes1609592066731 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(
      new Table({
        name: 'notes',
        columns: [
          {
            name: 'id',
            type: 'varchar',
            isPrimary: true,
            generationStrategy: 'uuid',
          },
            
          [...]
           
          {
            name: 'status',
            type: 'tinyint',
            isNullable: false,
          },
          {
            name: 'created_at',
            type: 'timestamp',
            default: 'now()',
          },
        ],
        foreignKeys: [
          {
            name: 'NoteUser',
            columnNames: ['user_id'],
            referencedColumnNames: ['id'],
            referencedTableName: 'users',
            onDelete: 'CASCADE',
            onUpdate: 'CASCADE',
          },
          {
            name: 'NoteTag',
            columnNames: ['tag_id'],
            referencedColumnNames: ['id'],
            referencedTableName: 'tags',
            onDelete: 'SET NULL',
            onUpdate: 'CASCADE',
          },
        ],
      }),
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable('notes');
  }
}

Em cada migration temos dois métodos: up e down. Isso porque um deles (o up) roda quando executamos a migration e o outro (o down) é rodado quando precisamos reverter a migration. A ideia é que este segundo método desfaça tudo o que o primeiro fez.

Autenticação

A autenticação foi feita por meio de JSON Web Tokens (JWT).

Se é a sua primeira vez ouvindo este termo, o fluxo é mais ou menos assim: quando um usuário se autentica no sistema (enviando seu e-mail e senha), o servidor gera pra ele um token que tem data de expiração e também pode conter outras informações úteis (mas não sensíveis) para futuras requisições, como o ID do usuário.

Durante as requisições seguintes do cliente, o JWT é enviado no cabeçalho da requisição e, caso esteja válido, a API irá permitir acesso aos recursos solicitados, sem a necessidade de se autenticar novamente.

Na prática, o usuário do Keep Clone se autentica com uma chamada POST à rota /sessions, enviando no body os campos email e password. Ex.:

{
	"email": "lucas@email.com",
	"password": "123456"
}

A resposta JSON do nosso servidor é dada neste formato:

{
  "user": {
    "id": "8ab65537-57a0-4d7f-a39e-b99db1356d1b",
    "email": "lucas@email.com",
    "created_at": "2021-01-14T20:26:52.000Z",
    "updated_at": "2021-01-14T20:26:52.000Z"
  },
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2MTA3MTY0NjMsImV4cCI6MTYxMDgwMjg2Mywic3ViIjoiOGFiNjU1MzctNTdhMC00ZDdmLWEzOWUtYjk5ZGIxMzU2ZDFiIn0.Cax6avEljyrjdHj3JFd_zeQHK8DOS9LoqRr95-W7JdI"
}

O token pode ser decodificado para visualizarmos seu conteúdo, já que ele é assinado, e não criptografado:

Keep Clone Automated Test Results

Na seção PAYLOAD temos o atributo sub que contêm o ID do usuário. Isso é útil porque quando o usuário logado fizer requisições pro backend usando esse token, já saberemos de que usuário se trata.

E esse conteúdo do token, apesar de poder ser visualizado, não pode ser alterado. O servidor neste caso é o único que cria tokens válidos para a nossa aplicação, porque ele conhece o “segredo” a ser utilizado na confecção deles.

Testes

Para fazer testes automatizados no backend, utilizei a famosa lib Jest, que é mantida pelo Facebook e considero bem útil/completa. Abaixo você pode ver como exemplo o arquivo que testa aquele service que vimos antes, o CreateNoteService:

// src/modules/notes/services/CreateNoteService.spec.ts

[Importações...]

let fakeUsersRepository: FakeUsersRepository;
let fakeTagsRepository: FakeTagsRepository;
let fakeNotesRepository: FakeNotesRepository;
let createNote: CreateNoteService;

describe('CreateNote', () => {
  beforeEach(() => {
    fakeUsersRepository = new FakeUsersRepository();
    fakeTagsRepository = new FakeTagsRepository();
    fakeNotesRepository = new FakeNotesRepository();
    createNote = new CreateNoteService(
      fakeUsersRepository,
      fakeTagsRepository,
      fakeNotesRepository,
    );
  });

  it('should be able to create note', async () => {
    const user = await fakeUsersRepository.create({
      email: 'user@email.com',
      password: '123456',
    });

    const note = await createNote.execute({
      user_id: user.id,
      title: 'Title',
      body: 'Body',
      color: NoteColors.NoColor,
    });

    expect(note).toHaveProperty('id');
  });

  it('should not be able to create note for non-existing user', async () => {
    await expect(
      createNote.execute({
        user_id: 'non-existing-user-id',
        title: 'Title',
        body: 'Body',
        color: NoteColors.NoColor,
      }),
    ).rejects.toBeInstanceOf(AppError);
  });
    
  [...]
   
});

Note que estou usando uns tais de FakeUsersRepository, FakeTagsRepository e FakeNotesRepository. Isso porque os nossos repositories de verdade acessam o banco de dados, o que requereria uma conexão com o mesmo, migrations em dia, etc. Além de que, após a execução dos testes, acabaríamos bagunçando a nossa base de dados (mesmo ela sendo apenas de desenvolvimento, não é exatamente algo legal).

Por isso, utilizo nos testes esses repositórios fake que implementam as mesmas funções só que “de mentirinha” (com os dados simplesmente sendo salvos num array). Daí crio uma nova instância desses repositórios fake a cada teste descrito, para que um teste não interfira no outro de forma alguma.

O resultado, no fim das contas, foi um backend com uma cobertura de testes de 100%, como é possível visualizar na imagem altamente satisfatória abaixo.

Keep Clone Automated Test Results

Pontos de melhoria

Apesar de ter criado o Keep Clone com o ❤️, sei que há alguns pontos em que ele pode ser melhorado, sendo alguns deles:

  • Implementar alguma estratégia de cache, em especial ao listar as notas;
  • Possíveis otimizações no banco de dados, já que não é algo que domino no momento e acredito que poderia ser feito algo nesse sentido;
  • Realizar testes automatizados no frontend também.

Além de algum aspecto que posso ter deixado passar despercebido, é claro 🤪.

Lembrando que se você quiser acessar o código completo do projeto, ele está disponível neste repositório no meu GitHub. E qualquer coisa é só entrar em contato!

E aí, gostou desta sequência de posts? O que mais você gostaria de ver aqui?