Executando verificação de segurança...
2

Como desenvolvi minha primeira biblioteca open-source pra Elixir

Esses dias eu cheguei em um nível onde simplesmente abandonei meu github pessoal, estava lá ele: alguns commits feitos em repositórios privados e meus projetos públicos totalmente abandonados e sem vida.

Vendo essa situação, comecei a procurar alguma ideia interessante pra criar com Elixir, uma linguagem incrível e que todo mundo deveria dar um pouco de atenção a ela. Com algumas pesquisas e alguns prompts jogados no Gemini, resolvi criar um wrapper do IBGE no elixir. Foi ai que achei a biblioteca brasilapi-ex, que ja abstrai uma parte da api do IBGE, porém voltada a abstrair outras API's para o cenário brasileiro.

Resolvi então começar o desenvolvimento, a primeira coisa: como padronizar meu projeto? O que é considerado boas práticas para criar bibliotecas em Elixir? A onde eu estou me metendo?

Primeiro passo: Library Guidelines. Ponto de partida pra criar uma biblioteca em Elixir, aqui contém uma série de instruções recomendadas pra manter uma boa padronização pra sua biblioteca. Algumas das instruções:

  • Todo projeto deve começar com snake_case.
  • Escrever testes com ExUnit(Framework de testes unitários do Elixir)
  • Escolher modo de versionamento(a maioria dos projetos utilizam SemVer)
  • Escrever documentação. A maioria dos projetos utilizam ExDoc pra isso.

Boas práticas e "Code Smells":
Seguindo uma recomendação da própria documentação do Elixir, mergulhei no catálogo de Anti-Patterns criado por Lucas Vegi e Marco Tulio Valente. Esse material virou meu guia de bolso: sempre que eu sentia que o código estava ficando "estranho" ou complexo demais, eu corria para lá para garantir que não estava cometendo nenhum pecado capital da linguagem.

Comecei então o desenvolvimento da biblioteca, seguindo a seguinte arquitetura:

lib/ex_ibge/
├── aggregate.ex
├── api.ex
├── name.ex
├── query.ex
├── utils.ex
├── geography/
└── locality/ 

Configurei um client básico para padronizar as consultas e criei a função bangify/1 para utilizar em bang functions(funções que retornam o valor caso dê certo ou gera uma raise expection, matando o processo):

defmodule ExIbge.Api do
  @base_url "https://servicodados.ibge.gov.br/api"
  @versions [:v1, :v2, :v3, :v4]

  @doc """
  Create a new client for a specific version of the API.
  """
  @type t :: Req.Request.t()

  @spec new!(atom()) :: t()
  def new!(version) when version in @versions do
    options = Application.get_env(:ex_ibge, :req_options, [])
    Req.new([base_url: "#{@base_url}/#{version}"] ++ options)
  end

  def new!(version) do
    raise ArgumentError, message: "Invalid version: #{version}"
  end

  @doc """
  Bangify a result.
  """
  @spec bangify({:ok, any()} | {:error, any()} | :ok) :: any()
  def bangify({:error, error}), do: raise(error)
  def bangify({:ok, body}), do: body
  def bangify(:ok), do: :ok
end

Para a implementação dos endpoints, segui um padrão importante em bibliotecas Elixir: oferecer escolha. Criei a função all/1, que segue o "caminho feliz" seguro retornando uma tupla {:ok, result} ou {:error, reason}, ideal para sistemas robustos que precisam tratar falhas.

Mas também implementei a versão all!/1 (a famosa bang function). Ela é perfeita para scripts rápidos ou para quando você quer encadear funções no pipe operator (|>) e prefere que o processo quebre imediatamente caso algo dê errado, sem precisar ficar "desembrulhando" tuplas manualmente.

  @spec all(Keyword.t()) :: {:ok, list(Country.t())} | {:error, any()}
  def all(query \\ []) do
    Req.get(Api.new!(:v1),
      url: "/localidades/paises",
      params: Query.build(query, Geography.Country)
    )
    |> handle_response()
  end
  
  @spec all!(Keyword.t()) :: list(Country.t())
  def all!(query \\ []) do
    Api.bangify(all(query))
  end

o Elixir realmente brilha é no tratamento da resposta da API. Em vez de encher o código de if/else para verificar se o status é 200 ou se o corpo é uma lista, utilizei Pattern Matching na função privada handle_response.

O código fica extremamente declarativo:

  • Se for 200 e o corpo for uma lista, mapeio para structs.
  • Se for 200 mas o corpo for um mapa único, envolvo numa lista.
  • Qualquer outro status vira erro.
  • Erros de conexão (HTTP) são capturados na última cláusula.
  defp handle_response({:ok, %{status: 200, body: data}}) when is_list(data) do
    countries = Enum.map(data, &Country.from_map/1)
    {:ok, countries}
  end

  defp handle_response({:ok, %{status: 200, body: data}}) when is_map(data) do
    {:ok, [Country.from_map(data)]}
  end

  defp handle_response({:ok, %{status: status}}) do
    {:error, {:http_error, status}}
  end

  defp handle_response({:error, error}) do
    {:error, error}
  end

No fim das contas, mais do que entregar um wrapper para o IBGE, esse projeto serviu para tirar a poeira do meu GitHub e, finalmente, colocar em prática conceitos de arquitetura e boas práticas que eu só via na teoria.

A biblioteca ainda está em crescimento (afinal, o IBGE tem dados infinitos), mas a base está sólida, testada e seguindo os padrões da comunidade. Se você também está estudando Elixir, quer contribuir ou apenas ver meu código, o código completo está aqui: ExIbge

Carregando publicação patrocinada...
1
0
1

Muito interessante a idéia. Só alguns pontos que me deixaram com dúvidas.

Escrever testes com ExUnit(Framework de testes unitários do Elixir)

  • Não conheço muito de Elixir. Como você escreveu os testes unitários da biblioteca?

  • Essa parte de pattern matching, como funciona no Elixir? Já tenho uma experiência básica com Gleam, e o pattern matching deles achei mais simples de entender kkkkk.

1

Não conheço muito de Elixir. Como você escreveu os testes unitários da biblioteca?

Perfeito. Eu escrevi utilizando o framework de testes unitários do Elixir ExUnit.

Exemplo do teste da função all/1:

  describe "all/1" do
    test "returns all aggregates grouped by research" do
      Req.Test.stub(ExIbge.Api, fn conn ->
        assert conn.request_path == "/api/v3/agregados"
        Req.Test.json(conn, [@research_fixture])
      end)

      assert {:ok, [research]} = Aggregate.all()
      assert research.id == "P1"
      assert research.name == "Pesquisa 1"
      assert length(research.aggregates) == 1
      assert hd(research.aggregates).id == "1705"
    end
  end

Aqui nesse teste antes de fazer a requisição eu intercepto e coloco dados esperados da resposta da API, segui esse modelo para os testes continuarem funcionando offline.

Essa parte de pattern matching, como funciona no Elixir? Já tenho uma experiência básica com Gleam, e o pattern matching deles achei mais simples de entender kkkkk.

Realmente é um pouco confuso pattern matching para desenvolvedores acostumados com linguagens imperativas.

Eu escrevi aqui um exemplo seguindo a mesma lógica em Elixir e Gleam pra ver se ficar mais claro:

Elixir:

defmodule Exemplo do
  def saudacao(:admin), do: "Bem-vindo, Chefe!"
  def saudacao(nome), do: "Olá, #{nome}"
end
pub fn saudacao(usuario) {
  case usuario {
    "admin" -> "Bem-vindo, Chefe!"
    nome -> "Olá, " <> nome
  }

}

Recomendo a leitura desse cheatsheet do site oficial do gleam:
Gleam For Elixir Users

1

Cara, me identifiquei muito com a parte de 'abandonar o github pessoal'. Às vezes a gente entra num ciclo de só fazer coisa privada e esquece como é massa subir algo pra comunidade. Massa demais que você usou o guia do Lucas Vegi, aquele PDF de code smells é uma bíblia pra quem tá começando com Elixir. Vou dar um check no repo, parabéns pela iniciativa!

0

Você viu os dados e prefiriu tratar/lidar com desdobramentos tecnologicos em detrimento de tratar/lidar com os desdobramentos das opções sociais que os dados apontam.

1
1