DEV Community

Cover image for Desenvolvendo APIs fortemente tipadas de ponta a ponta com tRPC
Douglas Moura for NodeBR

Posted on • Originally published at douglasmoura.dev

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)
}

Enter fullscreen mode Exit fullscreen mode

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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
// 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
Enter fullscreen mode Exit fullscreen mode
import { app } from './app'

const server = app()

try {
  server.listen({ port: 3000 })
} catch (err) {
  server.log.error(err)
  process.exit(1)
}
Enter fullscreen mode Exit fullscreen mode

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


Gostou desse artigo? Participe da comunidade da NodeBR e tenha a acesso a palestras, eventos ao vivo e artigos, totalmente sem custo: https://linktr.ee/nodebr

Top comments (6)

Collapse
 
nagref profile image
Fagner Lima

Bom demais Douglas!

Collapse
 
douglasdemoura profile image
Douglas Moura

Obrigado Fagner!

Collapse
 
ananeridev profile image
Ana Beatriz

demaaaaais

Collapse
 
douglasdemoura profile image
Douglas Moura

🙌

Collapse
 
cristuker profile image
Cristian Magalhães

Parabéns cara, ficou muito bom!

Collapse
 
douglasdemoura profile image
Douglas Moura

🙌