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