Pular para o conteúdo

Desenvolvendo APIs fortemente tipadas de ponta a ponta com tRPC

· 4 min. de leitura

Desenvolvendo APIs fortemente tipadas de ponta a ponta com tRPC

O desenvolvimento de APIs consiste em boa parte do trabalho feito por profissionais de tecnologia (pelo menos daqueles que trabalham com a web) e, ao longo do tempo, surgiram diversos padrões para ajudar a lidar com a complexidade de se desenvolver tais APIs, como REST, GraphQL e o RPC. Neste artigo, irei tratar do último, usando o tRPC.

Mas o que é RPC?

RPC (Remote Procedure Call - em português: chamada de função remota) é um mecanismo de comunicação entre dois computadores, onde um pode ser identificado como cliente e o outro por ser identificado como servidor. Do ponto de vista do cliente, chamar uma RPC é apenas uma questão de chamar uma função com seus devidos argumentos e aguardar uma resposta, a fim de continuar a execução do programa.

Diagrama de uma chamada de função remota (RPC)

E por que alguém faria isso? Ora, para distribuir seu sistema em servidores distintos, no momento que essa distribuição fizer sentido para o desenvolvimento do sistema.

Criando uma API com JavaScript e o padrão RPC

Agora que já sabemos o que é RPC, vamos criar uma API simples que tira proveito deste padrão, usando o Fastify). Desse modo poderemos entender melhor o padrão RPC com um exemplo prático e, de quebra, entender como o tRPC funciona.

Vamos criar uma API que irá criar, ler, atualizar e excluir itens em uma lista de tarefa. Vamos começar com o código básico do nosso servidor. Atente-se aos comentários do código abaixo:

server/src/index.js
import Fastify from "fastify";

// Neste arquivo, definiremos todas as procedures disponíveis
// na nossa API.
import * as procedures from "./procedures.js";

const fastify = Fastify();

// Nossa API terá um único endpoint: /rpc e
// só aceita requisições do tipo POST.
fastify.post("/rpc", async (req, res) => {
  // Toda requisição para /rpc deverá informar
  // qual procedure deseja chamar no corpo da requisição.
  if (!req.body?.procedure) {
    res.status(400).send({
      title: "Faltam parâmetros obrigatórios",
      details: 'O parâmetro "procedure" é obrigatório.',
    });
  }

  const procedure = procedures[req.body.procedure];

  // Se a procedure não estiver definida, retorna um erro
  if (!procedure || typeof procedure !== "function") {
    return res.status(404).send({
      title: "Procedure não encontrada",
      details: `A procedure ${req.body.procedure} não foi encontrada.`,
    });
  }

  try {
    // Chama a procedure com os argumentos, também
    // passados no corpo da requisição
    const response = procedure?.(req.body.args);

    // Eu espero que toda procedure retorne um objeto
    // com _status_ e _data_.
    return res.status(response?.status || 200).send(response?.data);
  } catch (error) {
    return res.status(error?.code || 500).send({
      title: error?.title || "Erro",
      details: error?.message || "Ocorreu um erro.",
    });
  }
});

// Inicia o servidor
try {
  await fastify.listen({ port: 3000 });
} catch (err) {
  fastify.log.error(err);
  process.exit(1);
}

Adicionamos as nossas procedures:

server/src/procedures.js
// Criamos um classe de erro personalizada
// só para as nossas procedures.
class ProcedureError extends Error {
  constructor(message, code, title) {
    super(message);
    this.title = title;
    this.code = code;
  }
}

// "Banco de dados"
export let tarefas = [
  {
    id: 1,
    title: "Tarefa 1",
  },
  {
    id: 2,
    title: "Tarefa 2",
  },
  {
    id: 3,
    title: "Tarefa 3",
  },
];

// Funções de CRUD

export const getTarefas = () => {
  return {
    status: 200,
    data: tarefas,
  };
};

export const getTarefa = (id) => {
  if (!id) {
    throw new ProcedureError(
      "O id é obrigatório",
      400,
      "Faltam parâmetros obrigatórios"
    );
  }

  const tarefa = tarefas.find((todo) => todo.id === id);

  if (!tarefa) {
    throw new ProcedureError(`Tarefa ${id} não encontrada`, 404);
  }

  return {
    status: 200,
    data: tarefa,
  };
};

export const addTarefa = (title) => {
  if (!title) {
    throw new ProcedureError(
      "O título é obrigatório",
      400,
      "Faltam parâmetros obrigatórios"
    );
  }

  const id = tarefas.length + 1;
  const tarefa = {
    id,
    title,
  };

  tarefas.push(tarefa);

  return {
    status: 201,
    data: { id },
  };
};

export const updateTarefa = ({ id, title }) => {
  if (!id || !title) {
    throw new ProcedureError(
      "O id e o título são obrigatórios",
      400,
      "Faltam parâmetros obrigatórios"
    );
  }

  const index = tarefas.findIndex((todo) => todo.id === id);

  if (index === -1) {
    throw new ProcedureError(`Tarefa ${id} não encontrada`, 404);
  }

  tarefas[index].title = title;

  return {
    status: 200,
  };
};

export const deleteTarefa = (id) => {
  if (!id) {
    throw new ProcedureError(
      "O id é obrigatório",
      400,
      "Faltam parâmetros obrigatórios"
    );
  }

  const index = tarefas.findIndex((todo) => todo.id === id);

  if (index === -1) {
    throw new ProcedureError(`Tarefa ${id} não encontrada`, 404);
  }

  tarefas = tarefas.filter((todo) => todo.id !== id);

  return {
    status: 204,
  };
};

Vamos iniciar a nossa API com node src/index.js e testar a chamada às nossas procedures. Vou usar o curl neste exemplo pois já que estamos usando um emulador de terminal, mas fique a vontade para usar a ferramenta que preferir. Ah, não se esqueça que todas as nossas requisições são do tipo POST:

curl --json '{"procedure": "getTarefas"}' http://localhost:3000/rpc

curl --json '{"procedure": "getTarefa", "args": 1}' http://localhost:3000/rpc

curl --json '{"procedure": "updateTarefa", "args": {"id": 1, "title": "Novo título"}}' http://localhost:3000/rpc

curl --json '{"procedure": "addTarefa", "args": "Nova tarefa"}' http://localhost:3000/rpc

curl --json '{"procedure": "deleteTarefa", "args": 4}' http://localhost:3000/rpc

Adicionando TypeScript

Como queremos uma API cujos tipos possam ser compartilhados do servidor para o cliente, vamos converter o nosso código JavaScript para TypeScript, da seguinte forma (e adicionar algumas melhorias ao mesmo tempo):

server/src/errors.ts
// Vamos separar a nossa classe ProcedureError
// em seu próprio arquivo:

export class ProcedureError extends Error {
  code: number;
  title: string | undefined;

  constructor(message: string, code: number, title?: string) {
    super(message);
    this.title = title;
    this.code = code;
  }
}

Agora, criamos o arquivo app.ts para colocar o código de configuração do servidor, excluindo a parte que inicia o servidor. Novamente, leia os comentários com atenção:

server/src/app.ts
import Fastify from "fastify";
import * as procedures from "./procedures";
import { ProcedureError } from "./errors";

export const app = () => {
  const fastify = Fastify();

  // Adiciona os tipos esperados no Body da requisição.
  fastify.post<{
    Body: {
      procedure: string;
      args?: any;
    };
  }>("/rpc", async (req, res) => {
    if (!req.body?.procedure) {
      res.status(400).send({
        title: "Faltam parâmetros obrigatórios",
        details: 'O parâmetro "procedure" é obrigatório.',
      });
    }

    // Fazemos coerção do tipo das procedures.
    const procedure =
      procedures?.[req.body?.procedure as keyof typeof procedures];

    if (!procedure || typeof procedure !== "function") {
      return res.status(404).send({
        title: "Procedure não encontrada",
        details: `A procedure ${req.body.procedure} não foi encontrada.`,
      });
    }

    try {
      const response = procedure(req.body.args);
      return res.status(response?.status || 200).send(response?.data);
    } catch (error) {
      // Definimos o tipo de erro esperado.
      const { code, title, message } = error as ProcedureError;

      return res.status(code || 500).send({
        title: title || "Erro",
        details: message || "Ocorreu um erro.",
      });
    }
  });

  return fastify;
};

O arquivo que inicializa o servidor:

server/src/server.ts
import { app } from "./app";

const server = app();

try {
  server.listen({ port: 3000 });
} catch (err) {
  server.log.error(err);
  process.exit(1);
}

E por fim, adicionaremos os tipos às nossas procedures:

server/src/procedures.ts
import { ProcedureError } from "./errors";

// O tipo de estrutura de dados do nosso CRUD.
type Tarefa = {
  id: number;
  title: string;
};

export let tarefas: Tarefa[] = [
  {
    id: 1,
    title: "Tarefa 1",
  },
  {
    id: 2,
    title: "Tarefa 2",
  },
  {
    id: 3,
    title: "Tarefa 3",
  },
];

// Tipo que define os argumentos e os retornos
// esperados de cada procedure.
// - TData é o tipo genérico que define o retorno dos dados.
// - TArgs é o tipo genérico que define os argumentos que a procedure recebe.
type Procedure<TData = unknown, TArgs = undefined> = (args?: TArgs) => {
  status: number;
  data?: TData;
};

export const getTarefas: Procedure<Tarefa[]> = () => {
  return {
    status: 200,
    data: tarefas,
  };
};

export const getTarefa: Procedure<Tarefa, Tarefa["id"]> = (id) => {
  if (!id) {
    throw new ProcedureError(
      "O id é obrigatório",
      400,
      "Faltam parâmetros obrigatórios"
    );
  }

  const tarefa = tarefas.find((todo) => todo.id === id);

  if (!tarefa) {
    throw new ProcedureError(`Tarefa ${id} não encontrada`, 404);
  }

  return {
    status: 200,
    data: tarefa,
  };
};

export const addTarefa: Procedure<{ id: Tarefa["id"] }, Tarefa["title"]> = (
  title
) => {
  if (!title) {
    throw new ProcedureError(
      "O título é obrigatório",
      400,
      "Faltam parâmetros obrigatórios"
    );
  }

  const id = tarefas.length + 1;
  const tarefa = {
    id,
    title,
  };

  tarefas.push(tarefa);

  return {
    status: 201,
    data: { id },
  };
};

export const updateTarefa: Procedure<undefined, Tarefa> = (args) => {
  if (!args?.id || !args?.title) {
    throw new ProcedureError(
      "O id e o título são obrigatórios",
      400,
      "Faltam parâmetros obrigatórios"
    );
  }

  const index = tarefas.findIndex((todo) => todo.id === args.id);

  if (index === -1) {
    throw new ProcedureError(`Tarefa ${args.id} não encontrada`, 404);
  }

  tarefas[index].title = args.title;

  return {
    status: 200,
  };
};

export const deleteTarefa: Procedure<undefined, Tarefa["id"]> = (id) => {
  if (!id) {
    throw new ProcedureError(
      "O id é obrigatório",
      400,
      "Faltam parâmetros obrigatórios"
    );
  }

  const index = tarefas.findIndex((todo) => todo.id === id);

  if (index === -1) {
    throw new ProcedureError(`Tarefa ${id} não encontrada`, 404);
  }

  tarefas = tarefas.filter((todo) => todo.id !== id);

  return {
    status: 204,
  };
};

// Exportamos os tipos da nossa API
// para serem usados do lado  do cliente.
export type API = {
  getTarefas: typeof getTarefas;
  getTarefa: typeof getTarefa;
  addTarefa: typeof addTarefa;
  updateTarefa: typeof updateTarefa;
  deleteTarefa: typeof deleteTarefa;
};

Com tudo pronto, agora podemos criar o código do nosso cliente e a única coisa que irei usar do código do servidor é o tipo API (que definimos no arquivo src/procedures.ts). Claro, para fazer isso, o código do cliente ou do servidor devem estar no mesmo monorepo ou serem compartilhados via git submodules:

client/src/index.ts
// Este é o caminho do código servidor, que pode estar no mesmo
// monorepo ou pasta, a depender da sua preferência.
import type { API } from "../../server/src/procedures";

// A função query é tudo que você precisará para
// interagir com as procedures do servidor.
const query = <Procedure extends keyof API>(
  procedure: Procedure,
  ...args: Parameters<API[Procedure]>
): Promise<ReturnType<API[Procedure]>> => {
  return fetch("http://localhost:3000/rpc", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ procedure, args: args[0] }),
  }).then((response) => response.json()) as any;
};

E agora, temos uma função que documenta todas as funções disponíveis no nosso servidor (e seus respectivos argumentos) apenas com seus tipos:

Mostrando o autocomplete das funções do servidor

É importante notar que estamos utilizando import type { API } from '../../server/src/procedures' (ênfase no type), pois, depois de compilar o nosso código para JavaScript, não queremos nenhum código do servidor disponível no cliente.

Agora que entendemos o que é RPC e construímos nossa própria API com este padrão, vamos ver como o tRPC pode nos ajudar.

Introduzindo o tRPC

O tRPC pega o conceito de uma API RPC que acabamos de implementar (e que foi inicialmente apresentado pelo Colin McDonell em seu blog) e adiciona uma experiência de desenvolvimento ainda melhor, com validação dos dados de entrada e saída com Zod (ou outra biblioteca de validação que você prefira) e até a geração do código do cliente com @tanstack/query e subscrições para envio de dados em tempo real via WebSockets. Dito isto, vamos refazer a API que construímos acima usando tRPC e Fastify:

server/src/app.ts
import Fastify from "fastify";
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import { appRouter } from "./router";

export const app = () => {
  const fastify = Fastify({ maxParamLength: 5000 });

  fastify.register(fastifyTRPCPlugin, {
    prefix: "/trpc",
    trpcOptions: { router: appRouter },
  });

  return fastify;
};
server/src/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";

type Tarefa = {
  id: number;
  title: string;
};

export let tarefas: Tarefa[] = [
  {
    id: 1,
    title: "Tarefa 1",
  },
  {
    id: 2,
    title: "Tarefa 2",
  },
  {
    id: 3,
    title: "Tarefa 3",
  },
];

export const t = initTRPC.create();
export const appRouter = t.router({
  // Toda operação de busca de dados é uma query
  // ---------------------\/
  getTarefas: t.procedure.query(() => tarefas),
  // Podemos tipar a entrada (e a saída de dados)
  // usando a excelente biblioteca Zod.
  getTarefa: t.procedure.input(z.number()).query(({ input: id }) => {
    const tarefa = tarefas.find((todo) => todo.id === id);

    if (!tarefa) {
      throw new Error("Tarefa não encontrada");
    }

    return tarefa;
  }),
  // Toda operação de alteração de dados é uma mutation
  // --------------------------------------\/
  addTarefa: t.procedure.input(z.string()).mutation(({ input: title }) => {
    const id = tarefas.length + 1;

    tarefas.push({ id, title });

    return id;
  }),
  updateTarefa: t.procedure
    .input(z.object({ id: z.number(), title: z.string() }))
    .mutation(({ input: { id, title } }) => {
      const tarefa = tarefas.find((todo) => todo.id === id);

      if (!tarefa) {
        throw new Error("Tarefa não encontrada");
      }

      tarefa.title = title;

      return id;
    }),
  deleteTarefa: t.procedure.input(z.number()).mutation(({ input: id }) => {
    const tarefa = tarefas.find((todo) => todo.id === id);

    if (!tarefa) {
      throw new Error("Tarefa não encontrada");
    }

    tarefas = tarefas.filter((todo) => todo.id !== id);

    return id;
  }),
});

// Exporta a definição de tipos do router
export type AppRouter = typeof appRouter;
import { app } from "./app";

const server = app();

try {
  server.listen({ port: 3000 });
} catch (err) {
  server.log.error(err);
  process.exit(1);
}

Se você leu os comentários do arquivo server/src/router.ts verá que o tRPC utiliza o mesmo modelo mental de operações do GraphQL, chamando as operações de busca de dados de query e as operações de alteração de dados de mutation (inclusive, pode-se dizer que o GraphQL é uma implementação de RPC que adiciona uma linguagem de busca e a documentação das entidades que são disponibilizadas pela API).

No cliente, podemos utilizar o serviço básico do tRPC (similar ao que fizemos em nossa função query no exemplo anterior:

Tipagem dos endpoint do cliente do tRPC

Ou, caso nosso frontend seja feito em React ou Next.js, podemos usar o cliente que se aproveita das facilidades do @tanstack/query.

Se você leu até e quer mexer no código utilizado neste artigo, acesse este repositório no GitHub. Você também pode ver a minha palestra no NodeBR #61 onde faço uma introdução ao tRPC.

Referências